Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 32b0a1ad35 docs(protocol): clarify same-host parent labels 2026-10-20 20:16:00 +02:00
Mohamed Boudra 583a61f992 test(app): normalize subagent fixtures through snapshots 2026-10-20 19:52:35 +02:00
Mohamed Boudra 6200859891 feat(hosts): delegate agents across connected hosts
Resolve MCP host operations through in-process commands for self and the existing routed client transport for other destinations. Keep ordinary parent labels, app sessions and idle finish notifications.
2026-10-20 19:40:07 +02:00
Mohamed Boudra 63c3bb8c42 test(e2e): keep transient developer toasts from stalling checks
Concurrent locator handlers can wait for a dismiss button another handler removed. Dismiss existing toast DOM at document lifetime, including its open shadow root, without intercepting app actions.
2026-10-20 08:06:15 +02:00
Mohamed Boudra 569b34263d refactor(hosts): use the ordinary client contract over channel transports
Keep queuing, permission codes and reconnection independent of transport selection. Track submitted request outcomes generically while retaining connection error codes and structured causes.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 71cc853f7b test(hosts): control physical write backpressure across platforms
Hold real TCP writes until assertions finish instead of assuming a paused receiver exhausts every platform receive buffer. Preserve physical send settlement, byte identity, capacity and sibling checks.
2026-10-20 07:59:01 +02:00
Mohamed Boudra f2dfa83e83 refactor(hosts): remove management settings UI from phase one
Keep connected-host management in the CLI and shared SDK while preserving app saved connections and plugin access.
2026-10-20 07:59:01 +02:00
Mohamed Boudra fb4ca20dbd fix(hosts): keep compact row actions clear of host labels
Give compact connected-host rows separate text and action widths so long names and producer labels cannot compete with primary controls.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 4e2aa97a74 fix(hosts): bound encrypted overload and initial acquisition
Check encrypted wire capacity before the async channel converts a resource limit into a physical connection error. Bound initial routed acquisition with the existing client timeout, then enable reconnect after admission.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 5ab9b8dcd4 fix(hosts): share daemon links across isolated routed sessions
Multiplex logical sessions over the monitored daemon connection and wrap original binary bytes in routing headers. Admit channels under their authenticated parent and current grants; preserve scoped delivery and physical send completion.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 4284209025 fix(cli): preserve validated addresses in daemon command hints 2026-10-20 07:59:01 +02:00
Mohamed Boudra 6ae87ec7e2 test(hosts): include relay coverage in existing CI contracts 2026-10-20 07:59:01 +02:00
Mohamed Boudra 630e8301ad fix(hosts): validate safe targets and verify encrypted relay routes 2026-10-20 07:59:01 +02:00
Mohamed Boudra 74bb3e76e4 feat(hosts): connect to saved hosts through a daemon 2026-10-20 07:59:01 +02:00
79 changed files with 6404 additions and 506 deletions
+1
View File
@@ -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 |
+3
View File
@@ -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
```
+85
View File
@@ -0,0 +1,85 @@
# 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 through `paseo --host A host` or `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 enabled registration owns one physical daemon link, shared by status monitoring and every
routed API scope. Each scope owns an isolated virtual session on B. A derives its identity from the
admitted origin session, producer, and logical SDK scope. B admits virtual sessions under the actual
physical link principal, then intersects forwarded caller grants with that link's current authority.
Changing or resuming a parent's grants closes its existing channels; reconnect re-admits demand with
current authority. A routed API cannot select a second hop, and saving a host grants no reverse access.
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 separately from the authenticated admission principal.
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
## Delegating agents
Agent MCP discovers destination IDs with `list_hosts`. `create_workspace`, `list_workspaces`, and
`create_agent` accept `serverId`; omission selects the calling daemon. Remote creation requires a
destination workspace and explicit provider/model. Caller filesystem defaults apply only on the
calling daemon.
The parent’s Subagents track shows qualified children across connected hosts. Opening a child uses
the ordinary conversation. The app retains one route per destination; a request through another
origin reports a conflict. It does not substitute an existing direct connection.
Finish notifications use the existing idle wait, including a pending initial run or an already idle
child. They name the server and agent. A daemon restart or route loss ends an outstanding notification
wait; inspect or wait for the child again rather than resending its prompt. Concurrent prompts gain
no additional turn attribution or delivery guarantee.
## Connections and recovery
The shared Node connector owns the target grammar and each connection attempt, including a fresh
SSH process. SSH reaches an already running daemon. The shared client connection lifecycle owns
physical reconnect and keepalive; the registry retains configuration, identity acceptance, and recovery
status. Unreachable registrations remain saved. Identity is pinned when discovered. Self and duplicate
registrations remain inspectable; a changed destination requires explicit identity replacement.
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
A completes capability and pinned-identity checks on each physical recovery before admitting channels. 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. The ordinary client queues new requests while connecting
within its existing deadline and rejects requests while disconnected. Connection loss clears old
waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError`
(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination
before retrying a mutation.
Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent
creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission
RPC codes pass through unchanged, and connection/recovery behavior matches direct clients.
Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8
channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to
base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed
payloads reserve space for the routing header inside that limit. Relay encryption retains its own
existing frame limit.
Host links cap saved registrations, simultaneous channels, handshake lifetime, frames, physical
output, and aggregate queued bytes. Capacity checks use the actual escaped outer JSON or binary
frame size. Awaitable output completes when the physical send completes. An overflowing channel
closes without closing siblings or the saved link. Routed sessions release resources on disconnect;
subscription demand is rebuilt by its owning client on reconnect.
See the [SDK reference](../public-docs/sdk/reference.md#clienthosts),
[CLI reference](../public-docs/cli.md#connected-hosts), and
[plugin reference](../public-docs/plugins/reference.md#connected-hosts).
+4
View File
@@ -31,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.
+1
View File
@@ -38178,6 +38178,7 @@
"dependencies": {
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -0,0 +1,58 @@
import { metroTest as test, expect } from "../support/fixtures";
import { installDeveloperToastDismissal } from "../support/developer-ui.mjs";
import type { Page } from "@playwright/test";
async function showTransientDeveloperToast(page: Page, shadow: boolean) {
await page.evaluate((useShadow) => {
const toast = document.createElement("div");
toast.id = "error-toast";
document.body.append(toast);
const root = useShadow ? toast.attachShadow({ mode: "open" }) : toast;
const button = document.createElement("button");
button.style.cssText = "width:32px;height:32px";
button.addEventListener("click", () => button.remove());
root.append(button);
}, shadow);
}
async function openTransientDeveloperToast(page: Page) {
await page.setContent("<button>Continue</button>");
await showTransientDeveloperToast(page, false);
await installDeveloperToastDismissal(page);
}
async function navigateWithDeveloperToastDismissal(page: Page) {
await installDeveloperToastDismissal(page);
await page.goto("data:text/html,<button>Continue</button>");
await showTransientDeveloperToast(page, true);
}
async function inspectLayoutWhileToastDisappears(page: Page) {
const action = page.getByRole("button", { name: "Continue", exact: true });
const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox()));
for (const box of boxes) expect(box).not.toBeNull();
await expect(page.locator("#error-toast").getByRole("button")).toHaveCount(0);
await action.click();
}
test("a disappearing developer toast does not stall concurrent layout checks", async ({ page }) => {
test.setTimeout(10000);
await openTransientDeveloperToast(page);
await inspectLayoutWhileToastDisappears(page);
});
test("developer toast dismissal survives navigation and observes Expo shadow-root updates", async ({
page,
}) => {
test.setTimeout(10000);
await navigateWithDeveloperToastDismissal(page);
await inspectLayoutWhileToastDisappears(page);
await page.evaluate(() => {
const root = document.getElementById("error-toast")!.shadowRoot!;
const replacement = document.createElement("button");
replacement.style.cssText = "width:32px;height:32px";
replacement.addEventListener("click", () => replacement.remove());
root.append(replacement);
});
await inspectLayoutWhileToastDisappears(page);
});
@@ -1,10 +1,17 @@
import { submitMessage } from "../support/helpers/composer";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
import {
delegateFleetThroughMcp,
openSubagentsTrack,
seedParentWithSubagent,
} from "../support/helpers/subagents";
test.use({ e2eInjectPaseoTools: true });
const SETTINGS_KEY = "@paseo:app-settings";
@@ -147,3 +154,100 @@ test("a preferred chat file link still opens beside its right-pane agent", async
await workspace.cleanup();
}
});
test("MCP delegates to self and B and opens both qualified children in ordinary conversations", async ({
page,
}) => {
const fleet = await delegateFleetThroughMcp();
let appSockets = 0;
let routeOpens = 0;
const activeDaemonSockets = new Set<unknown>();
const observationRequests: Record<string, number> = {};
page.on("websocket", (socket) => {
appSockets++;
if (new URL(socket.url()).pathname === "/ws") {
activeDaemonSockets.add(socket);
socket.on("close", () => activeDaemonSockets.delete(socket));
}
socket.on("framesent", ({ payload }) => {
if (typeof payload !== "string" || !payload.startsWith("{")) return;
if (String(payload).includes('"hosts.route.open.request"')) routeOpens++;
const outer = JSON.parse(String(payload));
const request = outer.type === "session" ? outer.message : null;
if (
request?.type &&
(request.subscribe !== undefined ||
request.observe === true ||
request.type.endsWith("set_subscription.request"))
)
observationRequests[request.type] = (observationRequests[request.type] ?? 0) + 1;
});
});
try {
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await openSubagentsTrack(page);
await expect(page.getByTestId(`subagents-track-row-${fleet.local.agentId}`)).toContainText(
"MCP child on A",
);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
"MCP child on B",
);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`)).toContainText(
"Fleet B",
);
await page.getByTestId(`subagents-track-row-${fleet.local.agentId}`).click();
await expect(page.getByTestId(`workspace-tab-agent_${fleet.local.agentId}`)).toBeVisible();
await submitMessage(page, "LOCAL_UI_TARGET");
await expect(page.getByText("LOCAL_UI_TARGET", { exact: true })).toBeVisible();
expect((await fleet.b.fetchAgent(fleet.local.agentId))?.agent.lastUserMessageAt).toBeNull();
await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click();
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${fleet.remote.agentId}`).click();
await expect(page).toHaveURL((url) =>
url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`),
);
await submitMessage(page, "REMOTE_UI_TARGET");
await expect(page.getByText("REMOTE_UI_TARGET", { exact: true })).toBeVisible();
expect((await fleet.a.fetchAgent(fleet.remote.agentId))?.agent.lastUserMessageAt).toBeNull();
await fleet.expectCompletion(fleet.local);
await fleet.expectCompletion(fleet.remote);
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await expect(
page.getByRole("button", { name: /Agent finished.*MCP child on A/ }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /Agent finished.*MCP child on B/ }),
).toBeVisible();
const owners = await page.evaluate(() => {
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as {
getHosts(): Array<{ serverId: string }>;
getSnapshot(
serverId: string,
): { connectionStatus: string; clientGeneration: number } | null;
};
return runtime.getHosts().map((host) => {
const snapshot = runtime.getSnapshot(host.serverId);
return {
serverId: host.serverId,
connectionStatus: snapshot?.connectionStatus,
clientGeneration: snapshot?.clientGeneration,
};
});
});
console.log("Fleet ordinary route counts", {
appSockets,
activeDaemonSockets: activeDaemonSockets.size,
routeOpens,
owners,
observationRequests,
});
} finally {
await fleet.close();
}
});
+24 -7
View File
@@ -1,10 +1,27 @@
/** @param {import('@playwright/test').Page} page */
export async function installDeveloperToastDismissal(page) {
// Developer chrome must not cover app actions or move the test's pointer/focus.
// Console errors remain in the runner's logs.
const dismiss = page.locator("#error-toast").getByRole("button", { name: "", exact: true });
await page.addLocatorHandler(dismiss, () => dismiss.dispatchEvent("click"), {
// A new logged error can replace the toast before the old one disappears.
noWaitAfter: true,
});
// Keep developer chrome out of app actions without pointer/focus changes.
// Locator handlers can deadlock when concurrent checks dismiss the same toast.
const install = () => {
let observedRoot;
const options = { childList: true, subtree: true };
const dismiss = () => {
const toast = document.getElementById("error-toast");
const root = toast?.shadowRoot ?? toast;
if (root && root !== observedRoot) {
observer.disconnect();
observer.observe(document, options);
observer.observe(root, options);
observedRoot = root;
}
// Expo's toast root contains dismiss buttons; its overlay has a different ID.
for (const button of root?.querySelectorAll("button") ?? [])
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
};
const observer = new MutationObserver(dismiss);
observer.observe(document, options);
dismiss();
};
await page.addInitScript(install);
await page.evaluate(install);
}
@@ -1,3 +1,7 @@
import { connectDaemonClient } from "./daemon-client-loader";
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
import { seedWorkspace } from "./seed-client";
import { getE2EDaemonPort } from "./daemon-port";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
@@ -320,3 +324,144 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom
await expect(detachButton).toBeVisible({ timeout: 30_000 });
await detachButton.click();
}
/** The primary children come from the real agent MCP endpoint on A. */
export async function delegateFleetThroughMcp() {
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
const { StreamableHTTPClientTransport } =
await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
const { z } = await import("zod");
type DaemonClient = import("@getpaseo/client/internal/daemon-client").DaemonClient;
const workspace = await seedWorkspace({ repoPrefix: "fleet-mcp-" });
const destination = await startIsolatedHostDaemon(`fleet_${crypto.randomUUID()}`);
const a = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "fleet-seed-A" });
const b = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "fleet-observe-B",
port: destination.port,
});
const serverId = a.getLastServerInfoMessage()!.serverId;
const mcp = new Client({ name: "fleet-parent", version: "1" });
const close = async () => {
await mcp.close();
await Promise.allSettled([a.close(), b.close()]);
await destination.close();
await workspace.cleanup();
};
const call = async (name: string, args: Record<string, unknown> = {}) => {
const response = await mcp.callTool({ name, arguments: args });
expect(response, name).not.toMatchObject({ isError: true });
return z.record(z.string(), z.unknown()).parse(response.structuredContent);
};
try {
const parent = await a.createAgent({
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Fleet parent",
modeId: "load-test",
});
const registration = await a.addHost({
connection: `127.0.0.1:${destination.port}`,
label: "Fleet B",
});
await expect
.poll(
async () =>
(await a.listHosts()).hosts.find(
(row) => row.registrationId === registration.registrationId,
)?.status,
)
.toBe("online");
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`),
),
);
expect(await call("list_hosts")).toMatchObject({
hosts: expect.arrayContaining([
expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }),
]),
});
const target = await call("create_workspace", {
serverId: destination.serverId,
isolation: "local",
path: workspace.repoPath,
title: "B workspace",
});
expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({
workspaces: expect.arrayContaining([
expect.objectContaining({ workspaceId: target.workspaceId }),
]),
});
const createChild = async (targetServerId: string, workspaceId: string, title: string) => {
const result = await call("create_agent", {
serverId: targetServerId,
workspaceId,
title,
provider: "mock/e2e-fast-stream",
settings: { modeId: "load-test" },
initialPrompt: `Finish ${title}`,
});
return { serverId: targetServerId, agentId: z.string().parse(result.agentId), title };
};
const local = await createChild(serverId, workspace.workspaceId, "MCP child on A");
const remote = await createChild(
destination.serverId,
z.string().parse(target.workspaceId),
"MCP child on B",
);
// Existing SDK reservation creates unrelated equal-ID fixtures; primary children remain MCP-created.
await b.createAgent({
agentId: local.agentId,
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: String(target.workspaceId),
title: "Unrelated on B",
modeId: "load-test",
});
await a.createAgent({
agentId: remote.agentId,
provider: "mock",
model: "e2e-fast-stream",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Unrelated on A",
modeId: "load-test",
});
return {
workspace,
parent,
serverId,
local,
remote,
a,
b,
close,
async expectCompletion(child: typeof local) {
await expect
.poll(async () => {
const timeline = await a.fetchAgentTimeline(parent.id, {
direction: "tail",
limit: 100,
});
return timeline.entries
.flatMap(({ item }) =>
item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [],
)
.filter(
(message) =>
message.event === "finished" &&
message.sender.id === child.agentId &&
message.text.includes(`Server: ${child.serverId}`),
).length;
})
.toBe(1);
},
};
} catch (error) {
await close();
throw error;
}
}
@@ -10,6 +10,7 @@ function connectionListen(connection: HostConnection): string | null {
case "directPipe":
return `pipe://${connection.path}`;
case "remoteSsh":
case "routed":
case "relay":
return null;
}
@@ -367,6 +367,11 @@ function useArchiveAgentPendingQuery() {
});
}
export function usePendingArchiveAgentKeys(): ReadonlySet<string> {
const pendingQuery = useArchiveAgentPendingQuery();
return useMemo(() => new Set(Object.keys(pendingQuery.data ?? {})), [pendingQuery.data]);
}
export function usePendingArchiveAgentIds(serverId: string): ReadonlySet<string> {
const pendingQuery = useArchiveAgentPendingQuery();
return useMemo(
+18 -5
View File
@@ -1,3 +1,6 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { memo, useCallback, type ReactElement } from "react";
import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
@@ -70,18 +73,28 @@ export const AgentTracks = memo(function AgentTracks({
},
[openInSidePane, tabId, workspaceKey],
);
const toast = useToast();
const handleOpenSubagent = useCallback(
(subagentId: string) => {
const session = useSessionStore.getState().sessions[serverId];
({ serverId: targetServerId, agentId: subagentId }: AgentAddress) => {
try {
getHostRuntimeStore().requireHostRoute(targetServerId, serverId);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
return;
}
const session = useSessionStore.getState().sessions[targetServerId];
const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId);
// A subagent in another workspace lives in that workspace's tabs.
if (agent?.workspaceId && agent.workspaceId !== workspaceId) {
navigateToAgent({ serverId, agentId: subagentId });
if (
targetServerId !== serverId ||
(agent?.workspaceId && agent.workspaceId !== workspaceId)
) {
navigateToAgent({ serverId: targetServerId, agentId: subagentId });
return;
}
openSubagentTarget({ kind: "agent", agentId: subagentId });
},
[openSubagentTarget, serverId, workspaceId],
[openSubagentTarget, serverId, workspaceId, toast],
);
const handleOpenProviderSubagent = useCallback(
(parentAgentId: string, subagentId: string) => {
+135 -5
View File
@@ -1,3 +1,6 @@
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
import type { OwnedSubscription } from "@getpaseo/client";
import type { HostDirectory } from "@getpaseo/protocol/hosts";
import { useSyncExternalStore, useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6";
@@ -113,6 +116,7 @@ export type ConnectionLinkImport =
| { status: "cancelled" };
export type ActiveConnection =
| { type: "routed"; endpoint: string; display: string }
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
| { type: "directPipe"; endpoint: string; display: "pipe" }
@@ -234,6 +238,12 @@ const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000;
function toActiveConnection(connection: HostConnection): ActiveConnection {
if (connection.type === "routed")
return {
type: "routed",
endpoint: connection.originServerId,
display: `via ${connection.originServerId}`,
};
if (connection.type === "directSocket") {
return {
type: "directSocket",
@@ -518,7 +528,9 @@ function probeIntervalForConnection(
return PROBE_MAX_BACKOFF_MS;
}
function createDefaultDeps(): HostRuntimeControllerDeps {
function createDefaultDeps(
resolveOrigin: (serverId: string) => DaemonClient | null = () => null,
): HostRuntimeControllerDeps {
const browserHostAvailable =
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
const browserAutomationCapabilities = browserHostAvailable
@@ -549,6 +561,15 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
...(host.password ? { password: host.password } : {}),
localCredential: () => readDesktopManagedLocalCredential(connection),
} satisfies Omit<DaemonClientConfig, "url">;
if (connection.type === "routed") {
const origin = resolveOrigin(connection.originServerId);
if (!origin) throw new Error(`Origin host ${connection.originServerId} is unavailable`);
return new DaemonClient({
...base,
url: "ws://routed-host/ws",
transportFactory: createHostChannelTransport(origin, host.serverId, clientId, null),
});
}
if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({
...base,
@@ -597,15 +618,40 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
},
});
},
connectToDaemon: ({ host, connection, timeoutMs }) =>
connectToDaemon(connection, {
connectToDaemon: async ({ host, connection, timeoutMs }) => {
if (connection.type === "routed") {
const origin = resolveOrigin(connection.originServerId);
if (!origin?.isConnected)
throw new Error(`Origin host ${connection.originServerId} is unavailable`);
const client = new DaemonClient({
url: "ws://routed-host/ws",
clientId: crypto.randomUUID(),
transportFactory: createHostChannelTransport(
origin,
host.serverId,
crypto.randomUUID(),
null,
),
reconnect: { enabled: false },
appVersion: resolveAppVersion() ?? undefined,
});
try {
await client.connect();
return { client, serverId: host.serverId, hostname: null };
} catch (error) {
await client.close();
throw error;
}
}
return connectToDaemon(connection, {
...(host.serverId ? { serverId: host.serverId } : {}),
...(host.password ? { password: host.password } : {}),
localCredential: () => readDesktopManagedLocalCredential(connection),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
capabilities: appCapabilities,
trace: nativePerformanceTrace,
}),
});
},
getClientId: () => getOrCreateClientId(),
mountClientHandlers: ({ client, host }) => {
const unmountServerData = mountServerDataPushRouter({
@@ -1488,6 +1534,11 @@ interface AgentDirectoryRefreshInput {
export class HostRuntimeStore {
private controllers = new Map<string, HostRuntimeController>();
private hostObservations = new Map<
string,
{ client: DaemonClient; observation: OwnedSubscription<HostDirectory>; unsubscribe: () => void }
>();
private fleetDirectoryDemand = new Map<string, () => void>();
private serverListeners = new Map<string, Set<() => void>>();
private globalListeners = new Set<() => void>();
private hostListListeners = new Set<() => void>();
@@ -1518,7 +1569,7 @@ export class HostRuntimeStore {
replicaRowStore?: ReplicaRowStore;
revokePushNotifications?: typeof revokePushNotifications;
}) {
this.deps = input?.deps ?? createDefaultDeps();
this.deps = input?.deps ?? createDefaultDeps((serverId) => this.getClient(serverId));
this.storage = input?.storage ?? AsyncStorage;
this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore());
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
@@ -2185,6 +2236,12 @@ export class HostRuntimeStore {
connection: HostConnection;
existingClient?: DaemonClient;
}): Promise<HostProfile> {
const retained = this.hosts.find((host) => host.serverId === input.serverId);
if (
retained?.connections.some((connection) => connection.type === "routed") &&
input.connection.type !== "routed"
)
throw new Error(`Host ${input.serverId} already has a retained routed connection`);
const now = new Date().toISOString();
const next = upsertHostConnectionInProfiles({
profiles: this.hosts,
@@ -2258,6 +2315,11 @@ export class HostRuntimeStore {
if (nextIds.has(serverId)) {
continue;
}
this.hostObservations.get(serverId)?.unsubscribe();
void this.hostObservations.get(serverId)?.observation.release();
this.hostObservations.delete(serverId);
this.fleetDirectoryDemand.get(serverId)?.();
this.fleetDirectoryDemand.delete(serverId);
this.controllers.delete(serverId);
this.lastConnectionStatusByServer.delete(serverId);
this.connectionStatusStartedAtByServer.delete(serverId);
@@ -2314,6 +2376,7 @@ export class HostRuntimeStore {
const snapshot = controller.getSnapshot();
this.syncSessionReplica(snapshot.serverId, snapshot);
this.syncDirectoryConnection(snapshot.serverId);
this.syncHostDiscovery(snapshot.serverId);
this.emit(snapshot.serverId);
});
void controller
@@ -2332,6 +2395,73 @@ export class HostRuntimeStore {
}
}
/** A retained destination uses its selected route; another route is an explicit conflict. */
requireHostRoute(serverId: string, originServerId: string): void {
if (serverId === originServerId) return;
const connection = this.hosts.find((host) => host.serverId === serverId)?.connections[0];
if (connection?.type !== "routed" || connection.originServerId !== originServerId) {
throw new Error(
`Host ${serverId} is already connected through another route. Requested via ${originServerId}.`,
);
}
}
private syncHostDiscovery(serverId: string): void {
const client = this.getClient(serverId);
const previous = this.hostObservations.get(serverId);
if (previous?.client === client) return;
previous?.unsubscribe();
void previous?.observation.release();
this.hostObservations.delete(serverId);
const host = this.hosts.find((candidate) => candidate.serverId === serverId);
if (
!client?.isConnected ||
!client.getLastServerInfoMessage()?.features?.connectedHosts ||
host?.connections.some((connection) => connection.type === "routed")
)
return;
const observation = client.observeHosts();
const apply = (directory: HostDirectory) => {
if (this.hostObservations.get(serverId)?.client !== client) return;
for (const target of directory.hosts) {
if (!target.serverId || target.serverId === serverId) continue;
if (!this.hosts.some((candidate) => candidate.serverId === target.serverId)) {
const now = new Date().toISOString();
const connection = {
id: `via:${serverId}`,
type: "routed" as const,
originServerId: serverId,
};
const profile: HostProfile = {
serverId: target.serverId,
label: target.label ?? target.serverId,
appearance: defaultHostAppearance(),
lifecycle: {},
connections: [connection],
preferredConnectionId: connection.id,
createdAt: now,
updatedAt: now,
};
this.setHostsAndSync([...this.hosts, profile]);
void this.persistHosts();
}
if (!this.fleetDirectoryDemand.has(target.serverId))
this.fleetDirectoryDemand.set(
target.serverId,
this.acquireDirectoryDemand(target.serverId),
);
}
};
const unsubscribe = observation.subscribe({
snapshot: apply,
update: (message) => {
if (message.type === "hosts.changed") apply(message.payload.directory);
},
});
this.hostObservations.set(serverId, { client, observation, unsubscribe });
void observation.ready.then(apply).catch(() => undefined);
}
private syncSessionReplica(serverId: string, snapshot: HostRuntimeSnapshot): void {
if (!snapshot.client) {
return;
@@ -106,6 +106,7 @@ const removeProfileIcon = <ThemedTrash2 size={ICON_SIZE.sm} uniProps={destructiv
const addProfileIcon = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
function formatHostConnectionLabel(connection: HostConnection, t: TFunction): string {
if (connection.type === "routed") return `Via ${connection.originServerId}`;
if (connection.type === "relay") {
return `${t("settings.host.badges.relay")} (${connection.relayEndpoint})`;
}
@@ -1,4 +1,5 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildWorkspaceTabSnapshot,
@@ -10,6 +11,7 @@ import { selectSubagentsForParent } from "@/subagents/select";
import { findPaneById, findPaneContainingTab } from "./workspace-layout-actions";
import { useWorkspaceLayoutStore } from "./workspace-layout-store";
import { useSessionStore, type Agent } from "./session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
@@ -73,7 +75,11 @@ const AGENT_DEFAULTS: Agent = {
};
function makeAgent(input: Partial<Agent> & Pick<Agent, "id">): Agent {
return { ...AGENT_DEFAULTS, ...input };
const agent = { ...AGENT_DEFAULTS, ...input };
return {
...normalizeAgentSnapshot(projectAgentSnapshot(agent), SERVER_ID),
projectPlacement: agent.projectPlacement,
};
}
function initializeAgents(agents: Agent[]): void {
@@ -142,7 +148,7 @@ describe("workspace subagents integration", () => {
const child = makeAgent({
id: "child-agent",
parentAgentId: "parent-agent",
labels: parentAddressLabels({ serverId: SERVER_ID, agentId: "parent-agent" }),
title: "Child agent",
});
const parent = makeAgent({
@@ -186,7 +192,7 @@ describe("workspace subagents integration", () => {
});
const child = makeAgent({
id: "child-agent",
parentAgentId: "parent-agent",
labels: parentAddressLabels({ serverId: SERVER_ID, agentId: "parent-agent" }),
title: "Child agent",
});
@@ -205,7 +211,7 @@ describe("workspace subagents integration", () => {
).map((row) => row.id),
).toEqual(["child-agent"]);
appendAgent({ ...child, parentAgentId: null, labels: {} });
appendAgent(makeAgent({ ...child, labels: {} }));
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual([
@@ -238,7 +244,7 @@ describe("workspace subagents integration", () => {
});
const child = makeAgent({
id: "child-agent",
parentAgentId: parent.id,
labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }),
title: "Cross-workspace child",
});
@@ -271,7 +277,7 @@ describe("workspace subagents integration", () => {
});
const child = makeAgent({
id: "child-agent",
parentAgentId: parent.id,
labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }),
title: "Child agent",
});
@@ -5,6 +5,7 @@ import type { PaseoSubagentRow, ProviderSubagentRow } from "./select";
function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSubagentRow {
return {
kind: "paseo",
address: { serverId: "server-1", agentId: id },
id,
provider: "codex",
title: id,
@@ -76,8 +77,8 @@ describe("createArchiveFinishedSubagents", () => {
[provider("native"), paseo("first"), paseo("second", "error"), paseo("resumed")],
{
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: (id) => {
getManagedSubagent: ({ agentId: id }) => current.get(id),
archiveManagedSubagent: ({ agentId: id }) => {
events.push(`archive:${id}`);
return id === "first" ? first.promise : second.promise;
},
@@ -130,8 +131,8 @@ describe("createArchiveFinishedSubagents", () => {
const calls: string[] = [];
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: (id) => {
getManagedSubagent: ({ agentId: id }) => current.get(id),
archiveManagedSubagent: ({ agentId: id }) => {
calls.push(id);
const response = responses.shift();
if (!response) throw new Error("Unexpected archive");
@@ -204,8 +205,8 @@ describe("createArchiveFinishedSubagents", () => {
]);
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async (id) => {
getManagedSubagent: ({ agentId: id }) => current.get(id),
archiveManagedSubagent: async ({ agentId: id }) => {
throw id === "first" ? firstError : secondError;
},
dismissProviderSubagents: () => undefined,
@@ -230,7 +231,7 @@ describe("createArchiveFinishedSubagents", () => {
const current = new Map([["failed", managed("failed")]]);
const archive = createArchiveFinishedSubagents([paseo("failed")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
getManagedSubagent: ({ agentId: id }) => current.get(id),
archiveManagedSubagent: async () => {
current.set("failed", managed("failed", "running"));
throw new Error("archive failed");
@@ -256,7 +257,7 @@ describe("createArchiveFinishedSubagents", () => {
const current = new Map([["failed", managed("failed")]]);
const archive = createArchiveFinishedSubagents([paseo("failed")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
getManagedSubagent: ({ agentId: id }) => current.get(id),
archiveManagedSubagent: async () => {
current.set("failed", managed("failed", "initializing"));
throw new Error("archive failed");
@@ -297,7 +298,7 @@ describe("createArchiveFinishedSubagents", () => {
const current = new Map([["failed", managed("failed")]]);
const archive = createArchiveFinishedSubagents([paseo("failed")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
getManagedSubagent: ({ agentId: id }) => current.get(id),
archiveManagedSubagent: async () => {
updateSnapshot(current);
throw error;
@@ -325,8 +326,8 @@ describe("createArchiveFinishedSubagents", () => {
const calls: string[] = [];
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async (id) => {
getManagedSubagent: ({ agentId: id }) => current.get(id),
archiveManagedSubagent: async ({ agentId: id }) => {
calls.push(id);
if (calls.length === 1) throw firstError;
if (calls.length === 2) {
@@ -384,8 +385,8 @@ describe("createArchiveFinishedSubagents", () => {
const archived: string[] = [];
const archive = createArchiveFinishedSubagents(rows, {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async (id) => {
getManagedSubagent: ({ agentId: id }) => current.get(id),
archiveManagedSubagent: async ({ agentId: id }) => {
archived.push(id);
current.set("resumed", managed("resumed", "running"));
},
+13 -11
View File
@@ -1,3 +1,4 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import type { Agent } from "@/stores/session-store";
import type { SubagentRow } from "./select";
@@ -22,8 +23,8 @@ export type ManagedSubagentSnapshot = Pick<Agent, "id" | "status" | "parentAgent
export interface ArchiveFinishedSubagentsDeps {
parentAgentId: string;
getManagedSubagent: (id: string) => ManagedSubagentSnapshot | undefined;
archiveManagedSubagent: (id: string) => Promise<void>;
getManagedSubagent: (address: AgentAddress) => ManagedSubagentSnapshot | undefined;
archiveManagedSubagent: (address: AgentAddress) => Promise<void>;
dismissProviderSubagents: (ids: string[]) => void;
}
@@ -55,12 +56,12 @@ function eligibleSignature(rows: readonly SubagentRow[]): string {
return identitySignature(rowIdentities(rows));
}
function managedRowIdentity(id: string): string {
return `paseo:${id}`;
function managedRowIdentity(address: AgentAddress): string {
return `paseo:${address.serverId}:${address.agentId}`;
}
function rowIdentity(row: SubagentRow): string {
return row.kind === "paseo" ? managedRowIdentity(row.id) : `provider:${row.id}`;
return row.kind === "paseo" ? managedRowIdentity(row.address) : `provider:${row.id}`;
}
function rowIdentities(rows: readonly SubagentRow[]): Set<string> {
@@ -169,7 +170,7 @@ async function runArchiveFinished(
reportProgress: (completedCount: number) => void,
): Promise<{ outcome: ArchiveFinishedOutcome; retryableFailureIds: Set<string> }> {
const providerIds = rows.filter((row) => row.kind === "provider").map((row) => row.id);
const paseoIds = rows.filter((row) => row.kind === "paseo").map((row) => row.id);
const paseoAddresses = rows.filter((row) => row.kind === "paseo").map((row) => row.address);
let completedCount = 0;
const outcome = emptyOutcome();
const retryableFailureIds = new Set<string>();
@@ -181,15 +182,16 @@ async function runArchiveFinished(
reportProgress(completedCount);
}
for (const id of paseoIds) {
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
for (const address of paseoAddresses) {
const id = address.agentId;
if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parentAgentId)) {
try {
await deps.archiveManagedSubagent(id);
await deps.archiveManagedSubagent(address);
outcome.archivedPaseoIds.push(id);
} catch (error) {
outcome.failures.push({ id, error });
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
retryableFailureIds.add(managedRowIdentity(id));
if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parentAgentId)) {
retryableFailureIds.add(managedRowIdentity(address));
}
}
} else {
@@ -2,9 +2,11 @@
* @vitest-environment jsdom
*/
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { useSubagentOpensInSheet } from "./opens-in-sheet";
@@ -77,7 +79,15 @@ if (!workspaceKey) throw new Error("workspace key");
function seedParentWithChild(): SubagentRow {
const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" };
const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" };
const child = normalizeAgentSnapshot(
projectAgentSnapshot({
...AGENT_DEFAULTS,
id: "child",
title: "Child",
labels: parentAddressLabels({ serverId: SERVER_ID, agentId: parent.id }),
}),
SERVER_ID,
);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setAgents(
SERVER_ID,
+49 -5
View File
@@ -53,7 +53,14 @@ const AGENT_DEFAULTS: Agent = {
};
function makeAgent(input: Partial<Agent> & Pick<Agent, "id">): Agent {
return { ...AGENT_DEFAULTS, ...input };
return {
...AGENT_DEFAULTS,
...input,
labels: {
...(input.parentAgentId ? { "paseo.parent-agent-id": input.parentAgentId } : {}),
...input.labels,
},
};
}
function setAgents(agents: Agent[]): void {
@@ -73,6 +80,41 @@ afterEach(() => {
});
describe("selectSubagentsForParent", () => {
it("keeps equal child IDs on different hosts qualified to the complete parent address", () => {
const parentId = "shared-parent";
const childId = "shared-child";
const local = makeAgent({
id: childId,
parentAgentId: parentId,
labels: { "paseo.parent-agent-id": parentId, "paseo.parent-server-id": SERVER_ID },
});
const remote = makeAgent({
id: childId,
serverId: "server-2",
parentAgentId: null,
labels: { "paseo.parent-agent-id": parentId, "paseo.parent-server-id": SERVER_ID },
});
setAgents([local]);
useSessionStore.getState().initializeSession("server-2", null as unknown as DaemonClient);
useSessionStore.getState().setAgents("server-2", new Map([[childId, remote]]));
try {
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{ serverId: SERVER_ID, parentAgentId: parentId },
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(rows).toHaveLength(2);
expect(rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ address: { serverId: SERVER_ID, agentId: childId } }),
expect.objectContaining({ address: { serverId: "server-2", agentId: childId } }),
]),
);
} finally {
useSessionStore.getState().clearSession("server-2");
}
});
it("hides cached provider children when the host does not support them", () => {
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
kind: "upsert",
@@ -315,6 +357,7 @@ describe("selectSubagentsForParent", () => {
expect(rows).toEqual([
{
kind: "paseo",
address: { serverId: SERVER_ID, agentId: "child" },
id: "child",
provider: "claude",
title: "Review child",
@@ -327,6 +370,7 @@ describe("selectSubagentsForParent", () => {
},
]);
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
"address",
"createdAt",
"description",
"id",
@@ -371,7 +415,7 @@ describe("selectSubagentsForParent", () => {
setAgents([
makeAgent({ id: "parent-a" }),
makeAgent({ id: "parent-b" }),
{ ...child, parentAgentId: "parent-b" },
{ ...child, parentAgentId: "parent-b", labels: { "paseo.parent-agent-id": "parent-b" } },
]);
expect(
@@ -409,7 +453,7 @@ describe("selectSubagentsForParent", () => {
serverId: SERVER_ID,
parentAgentId: "parent",
},
new Set(["child-b"]),
new Set([`${SERVER_ID}:child-b`]),
);
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
@@ -424,7 +468,7 @@ describe("selectSubagentsForParent", () => {
serverId: SERVER_ID,
parentAgentId: "parent",
},
new Set(["child"]),
new Set([`${SERVER_ID}:child`]),
);
expect(rows).toEqual([]);
@@ -541,7 +585,7 @@ describe("createSubagentsForParentSelector", () => {
const rows = createSubagentsForParentSelector(
params,
new Set(["child-b"]),
new Set([`${SERVER_ID}:child-b`]),
)(useSessionStore.getState());
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
+29 -18
View File
@@ -1,5 +1,7 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
import { useEffect, useMemo } from "react";
import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent";
import { usePendingArchiveAgentKeys } from "@/hooks/use-archive-agent";
import equal from "fast-deep-equal";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -8,6 +10,7 @@ import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messa
export interface PaseoSubagentRow {
kind: "paseo";
address: AgentAddress;
id: Agent["id"];
provider: Agent["provider"];
title: Agent["title"];
@@ -55,6 +58,7 @@ const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = [];
function toSubagentRow(agent: Agent): SubagentRow {
return {
kind: "paseo",
address: { serverId: agent.serverId, agentId: agent.id },
id: agent.id,
provider: agent.provider,
title: agent.title,
@@ -72,21 +76,19 @@ export function selectSubagentsForParent(
params: SelectSubagentsParams,
pendingArchiveIds: ReadonlySet<string>,
): SubagentRow[] {
const agents = state.sessions[params.serverId]?.agents;
if (!agents || agents.size === 0) {
return EMPTY_SUBAGENT_ROWS;
}
const rows: SubagentRow[] = [];
for (const agent of agents.values()) {
if (
agent.archivedAt ||
pendingArchiveIds.has(agent.id) ||
agent.parentAgentId !== params.parentAgentId
) {
continue;
for (const [serverId, session] of Object.entries(state.sessions)) {
for (const agent of session.agents.values()) {
const parent = getParentAddressFromLabels(agent.labels, serverId);
if (
agent.archivedAt ||
pendingArchiveIds.has(`${serverId}:${agent.id}`) ||
parent?.serverId !== params.serverId ||
parent.agentId !== params.parentAgentId
)
continue;
rows.push(toSubagentRow(agent));
}
rows.push(toSubagentRow(agent));
}
if (rows.length === 0) {
@@ -104,12 +106,21 @@ export function createSubagentsForParentSelector(
params: SelectSubagentsParams,
pendingArchiveIds: ReadonlySet<string>,
): (state: SessionStoreSnapshot) => SubagentRow[] {
let previousAgents: Map<string, Agent> | undefined;
let previousAgents: Array<[string, Map<string, Agent>]> = [];
let previousRows: SubagentRow[] | null = null;
return (state) => {
const agents = state.sessions[params.serverId]?.agents;
if (previousRows && agents === previousAgents) {
const agents = Object.entries(state.sessions).map(
([serverId, session]) => [serverId, session.agents] as [string, Map<string, Agent>],
);
if (
previousRows &&
agents.length === previousAgents.length &&
agents.every(
([serverId, map], index) =>
previousAgents[index]?.[0] === serverId && previousAgents[index]?.[1] === map,
)
) {
return previousRows;
}
@@ -159,7 +170,7 @@ export function selectProviderSubagentsForParent(
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
const { serverId, parentAgentId } = params;
const pendingArchiveIds = usePendingArchiveAgentIds(serverId);
const pendingArchiveIds = usePendingArchiveAgentKeys();
const selectPaseoRows = useMemo(
() => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds),
[serverId, parentAgentId, pendingArchiveIds],
@@ -13,6 +13,7 @@ function row(
): PaseoSubagentRow {
return {
kind: "paseo",
address: overrides.address ?? { serverId: "server-1", agentId: overrides.id },
id: overrides.id,
provider: overrides.provider ?? "codex",
title: overrides.title ?? `Agent ${overrides.id}`,
+23 -14
View File
@@ -1,3 +1,5 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { useHosts } from "@/runtime/host-runtime";
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
@@ -35,17 +37,17 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
export interface SubagentsTrackProps {
serverId: string;
rows: SubagentRow[];
onOpenSubagent: (id: string) => void;
onOpenSubagent: (address: AgentAddress) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
/**
* Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on
* close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list.
*/
opensInSheet: (row: SubagentRow) => boolean;
onArchiveSubagent: (id: string) => void;
onArchiveSubagent: (address: AgentAddress) => void;
onArchiveFinished?: () => void;
archiveFinishedStatus?: ArchiveFinishedStatus;
onDetachSubagent?: (id: string) => void;
onDetachSubagent?: (address: AgentAddress) => void;
}
const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
@@ -105,7 +107,7 @@ export function SubagentsTrack({
) : null}
{rows.map((row) => (
<SubagentsTrackRow
key={row.id}
key={row.kind === "paseo" ? `${row.address.serverId}:${row.id}` : row.id}
row={row}
serverId={serverId}
onOpenSubagent={onOpenSubagent}
@@ -180,11 +182,11 @@ function ArchiveFinishedRow({
interface SubagentsTrackRowProps {
serverId: string;
row: SubagentRow;
onOpenSubagent: (id: string) => void;
onOpenSubagent: (address: AgentAddress) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
opensInSheet: boolean;
onArchiveSubagent: (id: string) => void;
onDetachSubagent?: (id: string) => void;
onArchiveSubagent: (address: AgentAddress) => void;
onDetachSubagent?: (address: AgentAddress) => void;
}
function SubagentsTrackRow({
@@ -198,22 +200,24 @@ function SubagentsTrackRow({
}: SubagentsTrackRowProps): ReactElement {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const presentation = useRowPresentation(row, serverId);
const rowServerId = row.kind === "paseo" ? row.address.serverId : serverId;
const hostLabel = useHosts().find((host) => host.serverId === rowServerId)?.label ?? rowServerId;
const presentation = useRowPresentation(row, rowServerId);
const displayLabel =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
const handlePress = useCallback(() => {
if (row.kind === "provider") {
onOpenProviderSubagent(row.parentAgentId, row.id);
} else {
onOpenSubagent(row.id);
onOpenSubagent(row.address);
}
}, [onOpenProviderSubagent, onOpenSubagent, row]);
const handleArchivePress = useCallback(() => {
onArchiveSubagent(row.id);
}, [onArchiveSubagent, row.id]);
if (row.kind === "paseo") onArchiveSubagent(row.address);
}, [onArchiveSubagent, row]);
const handleDetachPress = useCallback(() => {
onDetachSubagent?.(row.id);
}, [onDetachSubagent, row.id]);
if (row.kind === "paseo") onDetachSubagent?.(row.address);
}, [onDetachSubagent, row]);
const actionsAlwaysVisible = isNative || isCompact;
const renderRow = useCallback(
@@ -225,7 +229,10 @@ function SubagentsTrackRow({
{displayLabel}
</Text>
{row.kind === "paseo" ? (
<PaseoSubagentFacts serverId={serverId} agentId={row.id} />
<>
<SubagentFacts rowId={row.id} text={rowServerId !== serverId ? hostLabel : ""} />
<PaseoSubagentFacts serverId={rowServerId} agentId={row.id} />
</>
) : (
<SubagentFacts rowId={row.id} text={presentation.subtitle} />
)}
@@ -253,6 +260,8 @@ function SubagentsTrackRow({
row.kind,
row.id,
serverId,
rowServerId,
hostLabel,
],
);
@@ -1,3 +1,5 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
import { useEffect, useMemo, useSyncExternalStore } from "react";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useSessionStore } from "@/stores/session-store";
@@ -32,8 +34,19 @@ export function useArchiveFinishedSubagents({
() =>
createArchiveFinishedSubagents([], {
parentAgentId,
getManagedSubagent: (id) => useSessionStore.getState().sessions[serverId]?.agents.get(id),
archiveManagedSubagent: (id) => archiveAgent({ serverId, agentId: id }),
getManagedSubagent: (address) => {
const agent = useSessionStore
.getState()
.sessions[address.serverId]?.agents.get(address.agentId);
if (!agent) return undefined;
const parent = getParentAddressFromLabels(agent.labels, address.serverId);
if (parent?.serverId !== serverId || parent.agentId !== parentAgentId) return undefined;
return { ...agent, parentAgentId: parent.agentId };
},
archiveManagedSubagent: async (address) => {
getHostRuntimeStore().requireHostRoute(address.serverId, serverId);
await archiveAgent(address);
},
dismissProviderSubagents: (ids) => {
useProviderSubagentStore.getState().hideFromTrack(serverId, parentAgentId, ids);
},
@@ -1,3 +1,5 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { useCallback } from "react";
import { useToast } from "@/contexts/toast-context";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
@@ -17,26 +19,30 @@ export interface UseArchiveSubagentInput {
serverId: string;
}
export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void {
export function useArchiveSubagent({
serverId: originServerId,
}: UseArchiveSubagentInput): (address: AgentAddress) => void {
const { archiveAgent } = useArchiveAgent();
const { serverId } = input;
const toast = useToast();
return useCallback(
(subagentId: string) => {
({ serverId, agentId: subagentId }: AgentAddress) => {
void requestArchiveSubagent(
{ serverId, subagentId },
{
getSubagent: (id): ResolveArchiveSubagentDialogInput | undefined =>
useSessionStore.getState().sessions[serverId]?.agents?.get(id),
confirm: confirmDialog,
archiveAgent,
archiveAgent: async (address) => {
getHostRuntimeStore().requireHostRoute(address.serverId, originServerId);
await archiveAgent(address);
},
reportError: (error) => {
toast.error(toErrorMessage(error));
},
},
);
},
[archiveAgent, serverId, toast],
[archiveAgent, originServerId, toast],
);
}
@@ -1,3 +1,5 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { useCallback } from "react";
import { useToast } from "@/contexts/toast-context";
import { i18n } from "@/i18n/i18next";
@@ -18,12 +20,13 @@ export interface UseDetachSubagentInput {
serverId: string;
}
export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: string) => void {
const { serverId } = input;
export function useDetachSubagent({
serverId: originServerId,
}: UseDetachSubagentInput): (address: AgentAddress) => void {
const toast = useToast();
return useCallback(
(subagentId: string) => {
({ serverId, agentId: subagentId }: AgentAddress) => {
void requestDetachSubagent(
{ serverId, subagentId },
{
@@ -31,6 +34,7 @@ export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: s
useSessionStore.getState().sessions[serverId]?.agents?.get(id),
confirm: confirmDialog,
detachAgent: async ({ serverId: targetServerId, agentId }) => {
getHostRuntimeStore().requireHostRoute(targetServerId, originServerId);
const client = useSessionStore.getState().sessions[targetServerId]?.client;
if (!client) {
throw new Error(i18n.t("workspaceSetup.errors.hostDisconnected"));
@@ -46,6 +50,6 @@ export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: s
},
);
},
[serverId, toast],
[originServerId, toast],
);
}
+11
View File
@@ -51,7 +51,14 @@ export interface RelayHostConnection {
daemonPublicKeyB64: string;
}
export interface RoutedHostConnection {
id: string;
type: "routed";
originServerId: string;
}
export type HostConnection =
| RoutedHostConnection
| DirectTcpHostConnection
| DirectSocketHostConnection
| DirectPipeHostConnection
@@ -128,6 +135,8 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
return false;
}
if (left.type === "routed" && right.type === "routed")
return left.originServerId === right.originServerId;
if (left.type === "directTcp" && right.type === "directTcp") {
return left.endpoint === right.endpoint && (left.useTls ?? false) === (right.useTls ?? false);
}
@@ -399,6 +408,7 @@ export function createRemoteSshHostConnection(input: {
}
const StoredHostConnectionSchema = z.discriminatedUnion("type", [
z.strictObject({ id: z.string(), type: z.literal("routed"), originServerId: z.string().min(1) }),
z.strictObject({
id: z.string().optional(),
type: z.literal("directTcp"),
@@ -446,6 +456,7 @@ export const StoredHostRegistrySchema = z.array(StoredHostProfileSchema);
type StoredHostConnection = z.infer<typeof StoredHostConnectionSchema>;
function normalizeStoredConnection(connection: StoredHostConnection): HostConnection | null {
if (connection.type === "routed") return connection;
if (connection.type === "directTcp") {
try {
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint));
+3 -2
View File
@@ -1,6 +1,6 @@
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
import {
TURN_LIVENESS_IDLE,
type ActiveTurnIdentity,
@@ -98,7 +98,8 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
? new Date(snapshot.attentionTimestamp)
: null;
const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null;
const parentAgentId = getParentAgentIdFromLabels(snapshot.labels);
const parent = getParentAddressFromLabels(snapshot.labels, serverId);
const parentAgentId = parent?.serverId === serverId ? parent.agentId : null;
// COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6.
// Old daemons expose only status. Normalize that legacy signal once so the rest
// of the app consumes one activity shape.
@@ -146,6 +146,8 @@ export async function buildClientConfig(
| "buildDesktopTransportUrl"
> = defaultDaemonConnectionDependencies,
): Promise<DaemonClientConfig> {
if (connection.type === "routed")
throw new Error("Routed host connections are resolved by HostRuntime");
const clientId = await deps.getClientId();
const desktopTransportFactory = deps.createDesktopTransportFactory();
const base = {
+3
View File
@@ -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));
+116
View File
@@ -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;
}
+2 -90
View File
@@ -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";
+10 -51
View File
@@ -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",
);
});
});
+26 -178
View File
@@ -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> {
+62 -1
View File
@@ -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");
});
+21 -7
View File
@@ -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>";
}
}
+9
View File
@@ -24,6 +24,14 @@
"./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"
},
"./internal/hosts": {
"types": "./dist/hosts/index.d.ts",
"default": "./dist/hosts/index.js"
}
},
"publishConfig": {
@@ -41,6 +49,7 @@
"dependencies": {
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -24,10 +24,16 @@ export function createRelayE2eeTransportFactory(args: {
baseFactory: DaemonTransportFactory;
daemonPublicKeyB64: string;
logger: TransportLogger;
assertOutboundCapacity?: (wireBytes: number) => void;
}): DaemonTransportFactory {
return ({ url }) => {
const base = args.baseFactory({ url });
return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger);
return createEncryptedTransport(
base,
args.daemonPublicKeyB64,
args.logger,
args.assertOutboundCapacity,
);
};
}
@@ -42,6 +48,7 @@ export function createEncryptedTransport(
base: DaemonTransport,
daemonPublicKeyB64: string,
logger: TransportLogger,
assertOutboundCapacity: (wireBytes: number) => void = () => {},
): DaemonTransport {
let channel: EncryptedChannel | null = null;
let opened = false;
@@ -148,6 +155,9 @@ export function createEncryptedTransport(
if (wireBytes > MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) {
throw messageTooLargeError(wireBytes, MAX_RELAY_WEBSOCKET_MESSAGE_BYTES);
}
// Encryption and its transport handoff are synchronous up to send's first await.
// Reject capacity failures here so a routed sender loses only its own channel.
assertOutboundCapacity(wireBytes);
void channel.send(payload).catch((error) => {
emitError(error);
});
+132 -1
View File
@@ -3,6 +3,7 @@ import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod";
import {
DaemonClient,
DaemonConnectionError,
supportsUsageReports,
type DaemonClientTrace,
type CreateAgentRequestOptions,
@@ -873,9 +874,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 () => {
@@ -7844,3 +7848,130 @@ test("measures an outbound message in UTF-8 bytes against the daemon's message l
expect(mock.sent).toHaveLength(1);
expect(client.getConnectionState().status).toBe("connected");
});
test("ordinary lifecycle queues new calls, distinguishes submitted outcomes, and never resends lost requests", async () => {
const transports = [createMockTransport(), createMockTransport(), createMockTransport()];
let attempt = 0;
const client = new DaemonClient({
url: "ws://transport",
clientId: "ordinary-lifecycle",
reconnect: { enabled: false },
transportFactory: () => transports[attempt++]!.transport,
});
clients.push(client);
const first = transports[0]!;
const connecting = client.connect();
const initial = client.getDaemonConfig("initial").catch((error: unknown) => error);
expect(first.sent).toEqual([]);
first.triggerOpen();
await connecting;
expect(parseSentFrame(first.sent[0]).requestId).toBe("initial");
first.triggerMessage(
wrapSessionMessage({
type: "rpc_error",
payload: { requestId: "initial", error: "Denied", code: "access_denied" },
}),
);
expect(await initial).toMatchObject({ code: "access_denied", serverId: "srv_test_1" });
const submitted = client
.patchDaemonConfig({ pluginsEnabled: false }, "submitted")
.catch((error: unknown) => error);
first.triggerClose({ code: 1001, reason: "physical loss" });
const unknown = await submitted;
expect(unknown).toBeInstanceOf(DaemonConnectionError);
expect(unknown).toMatchObject({
code: "DAEMON_CONNECTION_LOST",
outcome: "unknown",
serverId: "srv_test_1",
});
expect(() => client.ensureConnected()).not.toThrow();
const second = transports[1]!;
const neverSent = client
.patchDaemonConfig({ pluginsEnabled: true }, "never-sent")
.catch((error: unknown) => error);
expect(second.sent).toEqual([]);
second.triggerClose({ code: 1001, reason: "handshake lost" });
const unavailable = await neverSent;
expect(unavailable).toBeInstanceOf(DaemonConnectionError);
expect(unavailable).toMatchObject({ code: "DAEMON_CONNECTION_LOST", serverId: "srv_test_1" });
expect((unavailable as DaemonConnectionError).outcome).toBeUndefined();
client.ensureConnected();
const third = transports[2]!;
const recovery = client.getDaemonConfig("recovery").catch((error: unknown) => error);
expect(third.sent).toEqual([]);
third.triggerOpen();
expect(third.sent.map(parseSentFrame).map((frame) => frame.requestId)).toEqual(["recovery"]);
third.triggerMessage(
wrapSessionMessage({
type: "rpc_error",
payload: { requestId: "recovery", error: "Denied", code: "access_denied" },
}),
);
expect(await recovery).toMatchObject({ code: "access_denied" });
});
test.each([false, true])(
"synchronous send rejection leaves no unknown outcome after close (close during handoff: %s)",
async (closeDuringSend) => {
const first = createMockTransport();
const second = createMockTransport();
const rejected = Object.assign(new Error("Transport rejected this send"), {
code: "resource_limit",
});
const originalSend = first.transport.send;
first.transport.send = (data) => {
if (typeof data === "string" && data.includes("set_daemon_config_request")) {
if (closeDuringSend) first.triggerClose({ code: 1001, reason: "closed during send" });
throw rejected;
}
originalSend(data);
};
let attempt = 0;
const client = new DaemonClient({
url: "ws://transport",
clientId: "rejected-send",
reconnect: { enabled: false },
transportFactory: () => [first, second][attempt++]!.transport,
});
clients.push(client);
const connected = client.connect();
first.triggerOpen();
await connected;
await expect(client.patchDaemonConfig({ pluginsEnabled: false }, "rejected")).rejects.toBe(
rejected,
);
first.triggerClose({ code: 1001, reason: "lost connection" });
client.ensureConnected();
const queued = client.getDaemonConfig("not-submitted").catch((error: unknown) => error);
second.triggerClose({ code: 1001, reason: "lost handshake" });
const failure = (await queued) as DaemonConnectionError;
expect(failure).toBeInstanceOf(DaemonConnectionError);
expect(failure.outcome).toBeUndefined();
expect((rejected as Error & { outcome?: string }).outcome).toBeUndefined();
},
);
test("initial acquisition preserves structured transport failures and ordinary offline errors retain their cause", async () => {
const transport = createMockTransport();
const failure = Object.assign(new Error("Destination identity changed"), {
code: "server_id_mismatch",
serverId: "srv_expected",
});
const client = new DaemonClient({
url: "ws://transport",
clientId: "structured-acquisition",
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connecting = client.connect();
const rejected = expect(connecting).rejects.toBe(failure);
transport.triggerError(failure);
await rejected;
await expect(client.getDaemonConfig()).rejects.toMatchObject({
code: "DAEMON_CONNECTION_LOST",
cause: failure,
});
});
+243 -24
View File
@@ -1,4 +1,11 @@
import {
HOST_ROUTE_OPCODE,
encodeHostRouteFrame,
decodeHostRouteFrame,
type HostRouteFrame,
} from "@getpaseo/protocol/binary-frames/index";
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";
@@ -333,7 +340,7 @@ export type ConnectionState =
| { status: "idle" }
| { status: "connecting"; attempt: number }
| { status: "connected" }
| { status: "disconnected"; reason?: string }
| { status: "disconnected"; reason?: string; nextRetryAt?: string; failureCode?: string }
| { status: "disposed" };
export type DaemonEvent =
@@ -391,6 +398,7 @@ export interface DaemonClientConfig {
/** Deliver compact bodies/hash references to a caller-owned snapshot cache.
* The default keeps public SDK snapshot entries expanded. */
providerSnapshots?: "wire";
sessionPermissions?: import("@getpaseo/protocol/messages").DaemonPermission[];
url: string;
clientId: string;
clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub";
@@ -938,6 +946,7 @@ interface Waiter<T> {
reject(error: Error): void;
timeoutHandle: ReturnType<typeof setTimeout> | null;
requestId?: string;
submitted: boolean;
}
interface WaitHandle<T> {
@@ -995,11 +1004,16 @@ type CorrelatedResponsePayload<TType extends CorrelatedResponseType> =
CorrelatedResponsePayloads[TType];
export class DaemonConnectionError extends Error {
readonly serverId?: string;
readonly outcome?: "unknown";
constructor(
message: string,
readonly code: "DAEMON_CONNECTION_LOST" | "DAEMON_REQUEST_TIMEOUT" = "DAEMON_CONNECTION_LOST",
context?: { serverId?: string; outcome?: "unknown"; cause?: Error },
) {
super(message);
super(message, context?.cause ? { cause: context.cause } : undefined);
this.serverId = context?.serverId;
this.outcome = context?.outcome;
this.name = "DaemonConnectionError";
}
}
@@ -1008,8 +1022,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 +1040,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;
}
}
@@ -1282,6 +1306,8 @@ export class DaemonClient {
private connectResolve: (() => void) | null = null;
private connectReject: ((error: Error) => void) | null = null;
private lastErrorValue: string | null = null;
private destinationServerId: string | undefined;
private connectionFailure: Error | undefined;
private authFailureReasonValue: DaemonAuthFailureReason | null = null;
private connectionState: ConnectionState = { status: "idle" };
private readonly terminalStreams = new TerminalStreamRouter();
@@ -1479,6 +1505,7 @@ export class DaemonClient {
}
this.scheduleReconnect({
reason,
failure: event instanceof Error ? event : undefined,
event: "TRANSPORT_CLOSE",
reasonCode: "transport_closed",
});
@@ -1502,6 +1529,7 @@ export class DaemonClient {
this.lastErrorValue = reason;
this.scheduleReconnect({
reason,
failure: event instanceof Error ? event : undefined,
event: "TRANSPORT_ERROR",
reasonCode: "transport_error",
});
@@ -1518,6 +1546,7 @@ export class DaemonClient {
this.lastErrorValue = reason;
this.scheduleReconnect({
reason,
failure: event instanceof Error ? event : undefined,
event: "TRANSPORT_ERROR",
reasonCode: "transport_error",
});
@@ -1568,9 +1597,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;
@@ -1613,7 +1640,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 {
@@ -1638,6 +1667,119 @@ export class DaemonClient {
});
}
requireConnectedHosts(): void {
if (!this.isConnected)
throw Object.assign(new Error("The managing daemon is offline"), {
code: "host_offline",
serverId: this.destinationServerId,
});
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,
});
}
}
async openHostLink(channelId: string): Promise<string> {
this.requireConnectedHosts();
if (this.getLastServerInfoMessage()?.features?.hostMultiplexing !== true)
throw Object.assign(new Error("Update the destination daemon"), {
code: "unsupported_capability",
});
return (
await this.sendCorrelatedSessionRequest({
message: { type: "hosts.link.open.request", channelId },
responseType: "hosts.link.open.response",
})
).routeId;
}
sendHostFrame(routeId: string, data: string | Uint8Array): void {
if (typeof data === "string") {
this.sendSessionMessageStrict({
type: "hosts.route.frame.request",
routeId,
data,
binary: false,
});
} else {
if (!this.isConnected || !this.transport) throw this.unavailableError();
this.sendTransportFrame(encodeHostRouteFrame(routeId, data));
}
}
private readonly hostFrameListeners = new Set<(frame: HostRouteFrame) => void>();
subscribeHostFrames(handler: (frame: HostRouteFrame) => void): () => void {
this.hostFrameListeners.add(handler);
return () => this.hostFrameListeners.delete(handler);
}
cancelHostRoute(scopeId: string): void {
if (this.isConnected && this.transport)
this.sendSessionMessageStrict({ type: "hosts.route.close.request", scopeId });
}
closeHostRoute(routeId: string): void {
if (this.isConnected && this.transport)
this.sendSessionMessageStrict({ type: "hosts.route.close.request", routeId });
}
getConnectionState(): ConnectionState {
return this.connectionState;
}
@@ -1770,17 +1912,34 @@ export class DaemonClient {
this.endTraceSection(isOpen);
}
private sendJsonMessage(envelopeType: string, messageType: string, message: unknown): void {
private sendJsonMessage(
envelopeType: string,
messageType: string,
message: unknown,
submitted?: () => void,
): void {
this.traceInstant("paseo.ws.message.outbound", {
envelopeType,
messageType,
});
this.sendTransportFrame(JSON.stringify(message));
this.sendTransportFrame(JSON.stringify(message), submitted);
}
private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void {
private sendSessionPayload(payload: SessionInboundMessage): void {
this.sendJsonMessage("session", payload.type, { type: "session", message: payload }, () => {
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
for (const waiter of this.waiters) {
if (waiter.requestId === payload.requestId) waiter.submitted = true;
}
});
}
private sendTransportFrame(
frame: string | Uint8Array | ArrayBuffer,
submitted?: () => void,
): void {
if (!this.transport) {
throw new DaemonConnectionError("Transport not connected");
throw this.unavailableError();
}
// The daemon closes the connection on an oversized message, and creations resubmit on reconnect.
assertTransportFrameFits(frame);
@@ -1789,6 +1948,7 @@ export class DaemonClient {
size: String(getTransportFrameSize(frame)),
});
try {
submitted?.();
this.transport.send(frame);
} finally {
this.endTraceSection(isOpen);
@@ -1809,7 +1969,7 @@ export class DaemonClient {
}
const payload = SessionInboundMessageSchema.parse(message);
try {
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
this.sendSessionPayload(payload);
} catch (error) {
if (this.config.suppressSendErrors) {
return;
@@ -1848,7 +2008,7 @@ export class DaemonClient {
private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise<void> {
return this.sendWhenConnected(() => {
const payload = SessionInboundMessageSchema.parse(message);
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
this.sendSessionPayload(payload);
});
}
@@ -1888,7 +2048,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,6 +2102,7 @@ export class DaemonClient {
kind: "error",
error: new DaemonRpcError({
requestId: msg.payload.requestId,
serverId: this.destinationServerId,
error: msg.payload.error,
requestType: msg.payload.requestType,
code: msg.payload.code,
@@ -2053,13 +2214,20 @@ export class DaemonClient {
});
}
private unavailableError(): DaemonConnectionError {
return new DaemonConnectionError("Transport not connected", "DAEMON_CONNECTION_LOST", {
serverId: this.destinationServerId,
cause: this.connectionFailure,
});
}
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 {
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
this.sendSessionPayload(payload);
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
@@ -2874,7 +3042,9 @@ export class DaemonClient {
// ============================================================================
private readonly creations = new CreationClient({
supports: (feature) => this.lastServerInfoMessage?.features?.[feature] === true,
supports: (feature) => {
return this.lastServerInfoMessage?.features?.[feature] === true;
},
requestId: () => this.createRequestId(),
request: (kind, input) =>
kind === "workspace"
@@ -6310,6 +6480,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,
@@ -6484,6 +6657,16 @@ export class DaemonClient {
}
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
const hostFrame = decodeHostRouteFrame(rawBytes);
if (rawBytes[0] === HOST_ROUTE_OPCODE && !hostFrame) {
this.disposeTransport(1008, "Invalid host routing frame");
this.scheduleReconnect({ reason: "Invalid host routing frame", event: "INVALID_HOST_FRAME" });
return true;
}
if (hostFrame) {
for (const handler of this.hostFrameListeners) handler(hostFrame);
return true;
}
const fileFrame = decodeFileTransferFrame(rawBytes);
if (fileFrame) {
this.traceInstant("paseo.ws.message.inbound", {
@@ -6655,6 +6838,7 @@ export class DaemonClient {
}
private scheduleReconnect(input?: {
failure?: Error;
reason?: string;
event?: string;
reasonCode?: string;
@@ -6675,9 +6859,18 @@ export class DaemonClient {
// Clear all pending waiters and queued sends since the connection was lost
// and responses from the previous connection will never arrive.
this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost"));
this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost"));
this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost"));
const connectionError = new DaemonConnectionError(
reason ?? "Connection lost",
"DAEMON_CONNECTION_LOST",
{
serverId: this.destinationServerId,
cause: input?.failure,
},
);
this.connectionFailure = input?.failure;
this.clearWaiters(connectionError, true);
this.rejectPendingSendQueue(connectionError);
this.rejectPingProbe(connectionError);
this.terminalStreams.clearSlots();
this.lastServerInfoMessage = null;
@@ -6692,7 +6885,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;
}
@@ -6702,11 +6895,26 @@ export class DaemonClient {
private emitDisconnectedStateForReconnect(
reason: string | undefined,
input: { reason?: string; event?: string; reasonCode?: string } | undefined,
input: { reason?: string; event?: string; reasonCode?: string; failure?: Error } | undefined,
): void {
this.updateConnectionState(
{
status: "disconnected",
...(input?.failure && "code" in input.failure && typeof input.failure.code === "string"
? { failureCode: input.failure.code }
: {}),
...(this.shouldReconnect && this.config.reconnect?.enabled !== false
? {
nextRetryAt: new Date(
Date.now() +
Math.min(
(this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
2 ** this.reconnectAttempt,
this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
),
).toISOString(),
}
: {}),
...(reason ? { reason } : {}),
},
{
@@ -6800,6 +7008,8 @@ export class DaemonClient {
const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
if (serverInfo) {
this.lastServerInfoMessage = serverInfo;
this.destinationServerId = serverInfo.serverId;
this.connectionFailure = undefined;
if (this.connectionState.status === "connecting") {
this.resetConnectTimeout();
this.reconnectAttempt = 0;
@@ -6883,12 +7093,20 @@ export class DaemonClient {
}
}
private clearWaiters(error: Error): void {
private clearWaiters(error: Error, connectionLost = false): void {
for (const waiter of Array.from(this.waiters)) {
if (waiter.timeoutHandle) {
clearTimeout(waiter.timeoutHandle);
}
waiter.reject(error);
waiter.reject(
connectionLost && waiter.submitted && error instanceof DaemonConnectionError
? new DaemonConnectionError(error.message, error.code, {
serverId: error.serverId,
outcome: "unknown",
cause: error.cause instanceof Error ? error.cause : undefined,
})
: error,
);
}
this.waiters.clear();
}
@@ -6995,6 +7213,7 @@ export class DaemonClient {
reject: wrappedReject,
timeoutHandle,
requestId: options?.requestId,
submitted: false,
};
this.waiters.add(waiter);
});
+208
View File
@@ -0,0 +1,208 @@
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>;
}
export function createHostChannelTransport(
origin: DaemonClient,
serverId: string,
scopeId: string,
initialRouteId: string | null,
link = false,
): 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) Object.assign(error, { serverId });
for (const cleanup of cleanups) cleanup();
try {
if (routeId) origin.closeHostRoute(routeId);
else origin.cancelHostRoute(scopeId);
} catch {
/* Transport already closed. */
}
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.data, false);
}
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.subscribeHostFrames((frame) => {
if (frame.routeId === routeId) for (const handler of messages) handler(frame.payload, true);
}),
);
cleanups.push(
origin.subscribeConnectionStatus((connectionState) => {
if (connectionState.status !== "connected") close();
}),
);
let opening: Promise<string>;
if (first) opening = Promise.resolve(first);
else opening = link ? origin.openHostLink(scopeId) : origin.openHostRoute(serverId, scopeId);
first = null;
void opening
.then((id) => {
if (closed) {
origin.closeHostRoute(id);
return;
}
routeId = id;
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 instanceof Uint8Array ? data : new Uint8Array(data),
);
},
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);
} catch (error) {
throw Object.assign(error instanceof Error ? error : new Error(String(error)), {
serverId,
});
} finally {
owner.signal.removeEventListener("abort", cancel);
}
if (owner.signal.aborted) {
origin.closeHostRoute(routeId);
assertActive();
}
const client = new DaemonClient({
url: "ws://routed-host/ws",
clientId: scopeId,
transportFactory: createHostChannelTransport(origin, serverId, scopeId, routeId),
// Initial acquisition has a deadline; established scopes restore demand on loss.
reconnect: { enabled: false },
});
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();
client.setReconnectEnabled(true);
assertActive();
return { ...api, dispose: () => scope.release() };
} catch (error) {
const failure = Object.assign(
error instanceof Error ? error : new Error("Host handshake failed"),
{
code: error instanceof Error && "code" in error ? error.code : "host_offline",
serverId,
},
);
await scope.release();
throw failure;
}
},
};
}
+36
View File
@@ -267,6 +267,7 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own
"agents",
"config",
"dispose",
"hosts",
"observeEvents",
"projects",
"providers",
@@ -1734,3 +1735,38 @@ test("canceled timeline handles and captured state are collectible while their A
);
expect(result.stdout).toContain('"phase":"API alive"');
}, 20000);
test("qualified parents preserve foreign identity without resolving an equal local agent ID", async () => {
const { client, ws } = await connectClient();
const localParent = client.agents.ref("same-parent");
expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" });
const creating = client.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/repo/sdk",
parent: { serverId: "srv_parent_elsewhere", agentId: "same-parent" },
labels: { task: "preserved" },
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).not.toHaveProperty("callerAgentId");
expect(request).toMatchObject({
labels: {
task: "preserved",
"paseo.parent-agent-id": "same-parent",
"paseo.parent-server-id": "srv_parent_elsewhere",
},
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: request.requestId,
agentId: "agent_sdk",
agent: createAgent(),
},
}),
);
await creating;
await client.close();
expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" });
});
+54 -6
View File
@@ -1,3 +1,9 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
export type { AgentAddress } from "@getpaseo/protocol/addresses";
import { createHostActions, type PaseoHostActions } from "./hosts/index.js";
export type { PaseoHostActions } from "./hosts/index.js";
export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/protocol/hosts";
import type { OwnedSubscription } from "./connection/index.js";
export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js";
import type { DaemonClientConfig } from "./daemon-client.js";
@@ -246,7 +252,7 @@ export interface PaseoAgentCreateOptions {
onEvent?: (snapshot: CreationSnapshot) => void;
config: PaseoAgentConfig;
cwd: string;
parent?: string | PaseoAgentHandle;
parent?: string | AgentAddress | PaseoAgentHandle;
title?: PaseoAgentSessionConfig["title"];
env?: CreateAgentRequestMessage["env"];
prompt?: string;
@@ -340,6 +346,7 @@ export interface PaseoAgentTimelineHandle {
export interface PaseoAgentHandle {
readonly id: string;
readonly address: AgentAddress;
/**
* `workspaceId` through `archivedAt` mirror the last snapshot this handle
* observed. A handle from `ref()` reads `null` for all of them until
@@ -484,6 +491,7 @@ export interface PaseoConfigActions {
}
export interface PaseoApi {
readonly hosts: PaseoHostActions;
dispose(): Promise<void>;
observeEvents: DaemonClient["observeEvents"];
readonly terminals: PaseoTerminalActions;
@@ -526,10 +534,12 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient {
function toDaemonAgentCreateOptions(
options: PaseoAgentCreateOptions,
placement?: { workspaceId: string; cwd: string },
serverId?: string,
): CreateAgentRequestOptions {
const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options;
const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig;
const { provider, model } = parseProviderModel(providerModel);
const relationship = parent ? encodeParent(parent, serverId) : {};
return {
...requestOptions,
config: {
@@ -541,7 +551,12 @@ function toDaemonAgentCreateOptions(
...(providerOptions !== undefined ? { providerOptions } : {}),
},
...(placement ? { workspaceId: placement.workspaceId } : {}),
...(parent ? { callerAgentId: resolveAgentId(parent) } : {}),
...(parent
? {
...relationship,
labels: { ...requestOptions.labels, ...relationship.labels },
}
: {}),
...(prompt !== undefined ? { initialPrompt: prompt } : {}),
};
}
@@ -588,7 +603,13 @@ export function createPaseoApi(
options: PaseoAgentCreateOptions,
placement?: { workspaceId: string; cwd: string },
) => {
const agent = await daemonClient.createAgent(toDaemonAgentCreateOptions(options, placement));
const agent = await daemonClient.createAgent(
toDaemonAgentCreateOptions(
options,
placement,
daemonClient.getLastServerInfoMessage()?.serverId,
),
);
return createAgentHandle(agent);
};
const terminals = createTerminalActions(daemonClient, async (workspaceId) => {
@@ -695,6 +716,7 @@ export function createPaseoApi(
return {
dispose,
hosts: createHostActions(daemonClient, { own, signal: lifetime.signal, api: createPaseoApi }),
observeEvents,
terminals,
projects: {
@@ -712,7 +734,18 @@ export function createPaseoApi(
openWorkspace(daemonClient, createWorkspaceHandle, input, requestId),
create: async ({ requestId, agent, ...options }) => {
const result = await daemonClient.createWorkspace(
{ ...options, ...(agent ? { agent: toDaemonAgentCreateOptions(agent) } : {}) },
{
...options,
...(agent
? {
agent: toDaemonAgentCreateOptions(
agent,
undefined,
daemonClient.getLastServerInfoMessage()?.serverId,
),
}
: {}),
},
requestId,
);
if (result.error || !result.workspace) {
@@ -865,8 +898,14 @@ function createAgentHandleFactory(
const id = typeof agent === "string" ? agent : agent.id;
let current = typeof agent === "string" ? null : agent;
let serverId = daemonClient.getLastServerInfoMessage()?.serverId;
const handle: PaseoAgentHandle = {
id,
get address() {
serverId ??= daemonClient.getLastServerInfoMessage()?.serverId;
if (!serverId) throw new Error("Connect to a host before obtaining an agent address");
return { serverId, agentId: id };
},
timeline: {
append: (item) => daemonClient.appendAgentTimelineItem(id, item),
refetch: async (options) => {
@@ -1015,8 +1054,16 @@ function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string {
return typeof workspace === "string" ? workspace : workspace.id;
}
function resolveAgentId(agent: string | PaseoAgentHandle): string {
return typeof agent === "string" ? agent : agent.id;
function encodeParent(
parent: string | AgentAddress | PaseoAgentHandle,
serverId?: string,
): Pick<CreateAgentRequestOptions, "callerAgentId" | "labels"> {
if (typeof parent === "string") return { callerAgentId: parent };
const address = "address" in parent ? parent.address : parent;
if (!serverId) throw new Error("Connect to a host before creating a child");
return address.serverId === serverId
? { callerAgentId: address.agentId, labels: parentAddressLabels(address) }
: { labels: parentAddressLabels(address) };
}
function parseProviderModel(selection: string): { provider: string; model: string } {
@@ -1034,6 +1081,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."));
+25
View File
@@ -0,0 +1,25 @@
/** 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 = 256 * 1024 * 1024) {}
get bufferedBytes(): number {
return this.used;
}
assertCapacity(bytes: number): void {
if (bytes > this.limit - this.used)
throw Object.assign(new Error("Connected host buffer limit reached"), {
code: "resource_limit",
});
}
reserve(bytes: number): () => void {
this.assertCapacity(bytes);
this.used += bytes;
let released = false;
return () => {
if (!released) {
released = true;
this.used -= bytes;
}
};
}
}
+153
View File
@@ -0,0 +1,153 @@
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);
});
test("direct connector preserves 25 MiB binary frames in both directions", async () => {
const server = await endpoint();
const connected = once(server.wss, "connection");
const transport = createHostTransportFactory(server.url)({ url: server.url });
cleanup.push(async () => transport.close());
const ready = new Promise<void>((resolve) => transport.onOpen(resolve));
const received = new Promise<Uint8Array>((resolve, reject) => {
transport.onMessage((bytes, binary) => binary && resolve(bytes as Uint8Array));
transport.onError(reject);
});
const [socket] = (await connected) as [WebSocket];
await ready;
const bytes = new Uint8Array(25 * 1024 * 1024).fill(173);
const echoed = once(socket, "message");
transport.send(bytes);
const [sent, binary] = await echoed;
expect(binary).toBe(true);
expect(Buffer.from(sent as Uint8Array).equals(bytes)).toBe(true);
socket.send(bytes);
expect(Buffer.from(await received).equals(bytes)).toBe(true);
});
import {
createDaemonChannel,
generateKeyPair,
exportPublicKey,
type Transport,
} from "@getpaseo/relay/e2ee";
test("encrypted connector rejects overload synchronously and preserves the physical link for sibling traffic", async () => {
const server = await endpoint();
const keys = generateKeyPair();
const received: (string | ArrayBuffer)[] = [];
let peer!: WebSocket;
let ready!: Promise<unknown>;
server.wss.on("connection", (socket) => {
peer = socket;
const wire: Transport = {
send: (data) => socket.send(data),
close: () => socket.close(),
onmessage: null,
onclose: null,
onerror: null,
};
socket.on("message", (data, isBinary) => {
const bytes = Buffer.from(data as Buffer);
wire.onmessage?.({
data: isBinary
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
: bytes.toString(),
isBinary,
});
});
ready = createDaemonChannel(wire, keys, {
onmessage: (data) => {
received.push(data);
},
});
});
const port = new URL(server.url).port;
const budget = new HostTransportBudget(4 * 1024 * 1024);
const transport = createHostTransportFactory(
`relay://127.0.0.1:${port}/srv_slow?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`,
budget,
)({ url: server.url });
cleanup.push(async () => transport.close());
const errors: unknown[] = [];
let closed = false;
transport.onError((error) => errors.push(error));
transport.onClose(() => {
closed = true;
});
await new Promise<void>((resolve) => transport.onOpen(resolve));
await ready;
peer.pause();
const frame = new Uint8Array(1024 * 1024).fill(173);
let rejected: unknown;
for (let index = 0; index < 8; index++) {
try {
transport.send(frame);
} catch (error) {
rejected = error;
break;
}
}
expect(rejected).toMatchObject({ code: "resource_limit" });
expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(errors).toEqual([]);
expect(closed).toBe(false);
transport.send("sibling control");
peer.resume();
await expect.poll(() => received.includes("sibling control")).toBe(true);
expect(received.filter((data) => data instanceof ArrayBuffer).length).toBeGreaterThan(0);
expect(
received
.filter((data) => data instanceof ArrayBuffer)
.every((data) => Buffer.from(data).equals(frame)),
).toBe(true);
expect(server.wss.clients.size).toBe(1);
});
+195
View File
@@ -0,0 +1,195 @@
export { createHostChannelTransport } from "../hosts/index.js";
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 disposeSocket = () => {};
let assertOutboundCapacity = (bytes: number) => budget.assertCapacity(bytes);
let sshTunnel: SshTunnel | null = null;
const close = (code?: number, reason?: string) => {
if (closed) return;
closed = true;
try {
transport?.close(code, reason);
} finally {
try {
disposeSocket();
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: 100 * 1024 * 1024,
},
);
disposeSocket = () => socket.terminate();
assertOutboundCapacity = (bytes) => {
if (socket.bufferedAmount + bytes > 128 * 1024 * 1024) {
throw Object.assign(new Error("Host socket buffer limit reached"), {
code: "resource_limit",
});
}
budget.assertCapacity(bytes);
};
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);
assertOutboundCapacity(bytes);
const release = budget.reserve(bytes);
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() {} },
assertOutboundCapacity: (bytes) => assertOutboundCapacity(bytes),
});
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) => {
if (closed) return;
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">,
budget?: HostTransportBudget,
): DaemonClient {
const target = parseHostTarget(connection);
return new DaemonClient({
...config,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory: createHostTransportFactory(connection, budget),
});
}
+90
View File
@@ -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),
});
});
});
}
+4
View File
@@ -12,6 +12,10 @@
"./*": {
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
},
"./addresses": {
"types": "./dist/addresses.d.ts",
"default": "./dist/addresses.js"
}
},
"publishConfig": {
+5
View File
@@ -0,0 +1,5 @@
/** Agent identity is qualified by its daemon; transport and reachability are separate. */
export interface AgentAddress {
serverId: string;
agentId: string;
}
+22
View File
@@ -1,3 +1,5 @@
import type { AgentAddress } from "./addresses.js";
export const PARENT_SERVER_ID_LABEL = "paseo.parent-server-id";
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
const OPEN_AGENT_TAB_LABEL_PREFIX = "paseo.open-agent-tab.";
@@ -29,3 +31,23 @@ export function hasOpenAgentTab(labels: Record<string, unknown> | null | undefin
([label, value]) => isOpenAgentTabLabel(label) && value === "true",
);
}
// Absent parent server label means the same host.
export function getParentAddressFromLabels(
labels: Record<string, unknown> | null | undefined,
serverId: string,
): AgentAddress | null {
const agentId = getParentAgentIdFromLabels(labels);
if (!agentId) return null;
const parentServerId = labels?.[PARENT_SERVER_ID_LABEL];
return {
serverId:
typeof parentServerId === "string" && parentServerId.trim()
? parentServerId.trim()
: serverId,
agentId,
};
}
export function parentAddressLabels(parent: AgentAddress): Record<string, string> {
return { [PARENT_AGENT_ID_LABEL]: parent.agentId, [PARENT_SERVER_ID_LABEL]: parent.serverId };
}
@@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest";
import {
decodeBinaryFrame,
encodeHostRouteFrame,
decodeHostRouteFrame,
MAX_HOST_ROUTE_ID_BYTES,
encodeFileTransferFrame,
encodeTerminalStreamFrame,
FileTransferOpcode,
@@ -51,3 +54,38 @@ describe("binary frame demux", () => {
expect(decodeBinaryFrame(new Uint8Array([0xff, 0]))).toBeNull();
});
});
describe("host route binary framing", () => {
it("wraps terminal and file frames unchanged and leaves their direct interpretation intact", () => {
const frames = [
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Input, slot: 7, payload: "echo" }),
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId: "upload",
payload: new Uint8Array([0, 255, 128, 1]),
}),
];
for (const payload of frames) {
const wire = encodeHostRouteFrame("channel", payload);
const decoded = decodeBinaryFrame(wire);
expect(decoded).toEqual({ kind: "host_route", frame: { routeId: "channel", payload } });
expect(wire.subarray(10)).toEqual(payload);
expect(decodeBinaryFrame(decodeHostRouteFrame(wire)!.payload)).toEqual(
decodeBinaryFrame(payload),
);
}
});
it("rejects truncated, oversized identifiers and invalid UTF-8 without throwing", () => {
for (const bytes of [
new Uint8Array([0x30]),
new Uint8Array([0x30, 0, 4, 65]),
new Uint8Array([0x30, 0, 0]),
new Uint8Array([0x30, 1, 1]),
new Uint8Array([0x30, 0, 1, 255]),
])
expect(decodeBinaryFrame(bytes)).toBeNull();
expect(() =>
encodeHostRouteFrame("a".repeat(MAX_HOST_ROUTE_ID_BYTES + 1), new Uint8Array()),
).toThrow("Invalid host route ID");
});
});
@@ -1,3 +1,4 @@
import { HOST_ROUTE_OPCODE, decodeHostRouteFrame, type HostRouteFrame } from "./host-route.js";
import {
decodeFileTransferFrame,
FileTransferOpcode,
@@ -10,11 +11,16 @@ import {
} from "./terminal.js";
export type BinaryFrame =
| { kind: "host_route"; frame: HostRouteFrame }
| { kind: "terminal"; frame: TerminalStreamFrame }
| { kind: "file_transfer"; frame: FileTransferFrame };
export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
switch (bytes[0]) {
case HOST_ROUTE_OPCODE: {
const frame = decodeHostRouteFrame(bytes);
return frame ? { kind: "host_route", frame } : null;
}
case TerminalStreamOpcode.Output:
case TerminalStreamOpcode.Input:
case TerminalStreamOpcode.Resize:
@@ -0,0 +1,33 @@
import { MAX_WEBSOCKET_MESSAGE_BYTES } from "../websocket-message-limit.js";
/** Routing adds a bounded header; the enclosed terminal/file frame is never re-encoded. */
export const HOST_ROUTE_OPCODE = 0x30;
export const MAX_HOST_ROUTE_ID_BYTES = 256;
export const MAX_HOST_ROUTE_HEADER_BYTES = 3 + MAX_HOST_ROUTE_ID_BYTES;
export const MAX_ROUTED_PAYLOAD_BYTES = MAX_WEBSOCKET_MESSAGE_BYTES - MAX_HOST_ROUTE_HEADER_BYTES;
export interface HostRouteFrame {
routeId: string;
payload: Uint8Array;
}
export function encodeHostRouteFrame(routeId: string, payload: Uint8Array): Uint8Array {
const id = new TextEncoder().encode(routeId);
if (!id.length || id.length > MAX_HOST_ROUTE_ID_BYTES) throw new Error("Invalid host route ID");
if (payload.byteLength > MAX_ROUTED_PAYLOAD_BYTES) throw new Error("Host frame is too large");
const frame = new Uint8Array(3 + id.length + payload.byteLength);
frame[0] = HOST_ROUTE_OPCODE;
new DataView(frame.buffer).setUint16(1, id.length);
frame.set(id, 3);
frame.set(payload, 3 + id.length);
return frame;
}
export function decodeHostRouteFrame(bytes: Uint8Array): HostRouteFrame | null {
if (bytes[0] !== HOST_ROUTE_OPCODE || bytes.length < 3) return null;
const length = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(1);
if (!length || length > MAX_HOST_ROUTE_ID_BYTES || bytes.length < 3 + length) return null;
if (bytes.length - 3 - length > MAX_ROUTED_PAYLOAD_BYTES) return null;
try {
const routeId = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(3, 3 + length));
return { routeId, payload: bytes.subarray(3 + length) };
} catch {
return null;
}
}
@@ -1,3 +1,4 @@
export * from "./demux.js";
export * from "./file-transfer.js";
export * from "./terminal.js";
export * from "./host-route.js";
+76
View File
@@ -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");
}
});
+137
View File
@@ -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);
}
+123
View File
@@ -0,0 +1,123 @@
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(100 * 1024 * 1024),
binary: z.literal(false),
});
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.literal(false) }),
});
export const HostsRouteClosedSchema = z.object({
type: z.literal("hosts.route.closed"),
payload: z.object({ subscriptionId: z.string(), code: z.string(), message: z.string() }),
});
// Logical B sessions are admitted under the authenticated physical link, never hello auth.
export const HostsLinkOpenRequestSchema = z.object({
type: z.literal("hosts.link.open.request"),
requestId: z.string(),
channelId: z.string().min(1).max(256),
});
export const HostsLinkOpenResponseSchema = z.object({
type: z.literal("hosts.link.open.response"),
payload: z.object({ requestId: z.string(), routeId: z.string() }),
});
+47
View File
@@ -1,4 +1,23 @@
import { AgentMessageSchema } from "./agent-message.js";
import {
HostsListRequestSchema,
HostsAddRequestSchema,
HostsUpdateRequestSchema,
HostsRemoveRequestSchema,
HostsLinkOpenRequestSchema,
HostsRouteOpenRequestSchema,
HostsRouteFrameRequestSchema,
HostsRouteCloseRequestSchema,
HostsListResponseSchema,
HostsChangedSchema,
HostsAddResponseSchema,
HostsUpdateResponseSchema,
HostsRemoveResponseSchema,
HostsLinkOpenResponseSchema,
HostsRouteOpenResponseSchema,
HostsRouteFrameSchema,
HostsRouteClosedSchema,
} from "./hosts.js";
import { PluginRegistryIdentitySchema } from "./plugin-registry.js";
import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js";
export {
@@ -3202,6 +3221,15 @@ export const SubscriptionReleaseResponseSchema = z.object({
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HostsListRequestSchema,
HostsAddRequestSchema,
HostsUpdateRequestSchema,
HostsRemoveRequestSchema,
HostsLinkOpenRequestSchema,
HostsRouteOpenRequestSchema,
HostsRouteFrameRequestSchema,
HostsRouteCloseRequestSchema,
BrowserHostRegisterRequestSchema,
SubscriptionReleaseRequestSchema,
SessionEventsSetSubscriptionRequestSchema,
@@ -3571,6 +3599,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.
@@ -3592,6 +3622,8 @@ export const ServerInfoStatusPayloadSchema = z
internalWorkspaces: z.boolean().optional(),
backgroundWorkspaces: z.boolean().optional(),
providersSnapshot: z.boolean().optional(),
connectedHosts: z.boolean().optional(),
hostMultiplexing: z.boolean().optional(),
usageSources: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
@@ -6871,6 +6903,16 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HostsListResponseSchema,
HostsChangedSchema,
HostsAddResponseSchema,
HostsUpdateResponseSchema,
HostsRemoveResponseSchema,
HostsLinkOpenResponseSchema,
HostsRouteOpenResponseSchema,
HostsRouteFrameSchema,
HostsRouteClosedSchema,
BrowserHostRegisterResponseSchema,
SubscriptionReleaseResponseSchema,
SessionEventsSetSubscriptionResponseSchema,
@@ -7563,6 +7605,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(),
@@ -195,6 +195,7 @@ export const PASEO_TOOL_CATALOG = {
context: workspaceContext(),
detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] },
},
list_hosts: { label: "List hosts" },
list_workspaces: { label: "List workspaces" },
rename_workspace: {
label: "Rename workspace",
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && 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",
@@ -1,3 +1,4 @@
import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client";
import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js";
import type { Logger } from "pino";
@@ -10,7 +11,10 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import {
getParentAgentIdFromLabels,
getParentAddressFromLabels,
} from "@getpaseo/protocol/agent-labels";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
@@ -292,7 +296,7 @@ async function resolvePromptSource(
manager: Pick<AgentManager, "getAgent">,
storage: AgentStorage,
): Promise<AgentPromptSource | undefined> {
if (!source) return undefined;
if (!source || source.title) return source;
const title = (
manager.getAgent(source.agentId)?.config.title ?? (await storage.get(source.agentId))?.title
)?.trim();
@@ -401,6 +405,7 @@ export interface SetupFinishNotificationParams {
agentStorage: AgentStorage;
childAgentId: string;
callerAgentId: string;
childServerId?: string;
requireParentOwnership?: boolean;
/** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */
dispatchResult?: Promise<PromptDispatchDisposition | false>;
@@ -429,6 +434,7 @@ const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000;
interface FinishNotificationBodyInput {
childAgentId: string;
serverId?: string;
title: string;
reason: FinishNotificationReason;
lastAssistantMessage: string | null;
@@ -438,12 +444,14 @@ interface FinishNotificationBodyInput {
function formatFinishNotificationBody(params: FinishNotificationBodyInput): string {
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
const sections = [statusLine];
if (params.serverId) sections.push(`Server: ${params.serverId}`);
if (params.reason === "needs permission" && params.permissionRequest) {
sections.push(
"Respond with `respond_to_permission` using the `agentId` and `requestId` below.",
`<permission-request>\n${JSON.stringify(
{
agentId: params.childAgentId,
...(params.serverId ? { serverId: params.serverId } : {}),
requestId: params.permissionRequest.id,
request: params.permissionRequest,
},
@@ -480,6 +488,101 @@ const armedFinishNotifications = new WeakMap<
Map<string, Set<ArmedFinishNotification>>
>();
/** Creation waits and accepted follow-ups share notification ownership. */
export function setupFinishWait(params: {
agentManager: AgentManager;
agentStorage: AgentStorage;
originServerId?: string;
childServerId?: string;
childAgentId: string;
callerAgentId: string;
wait(signal: AbortSignal): Promise<WaitForFinishResult>;
afterPermission(requestId: string, signal: AbortSignal): Promise<void>;
logger: Logger;
}): () => void {
const controller = new AbortController();
const byManager =
armedFinishNotifications.get(params.agentManager) ??
new Map<string, Set<ArmedFinishNotification>>();
armedFinishNotifications.set(params.agentManager, byManager);
const key = JSON.stringify([
params.childServerId ?? null,
params.childAgentId,
params.callerAgentId,
]);
const registrations = byManager.get(key) ?? new Set<ArmedFinishNotification>();
byManager.set(key, registrations);
for (const previous of registrations) previous.stop();
byManager.set(key, registrations);
let superseded = Promise.resolve(false);
const stop = () => {
controller.abort();
registrations.delete(registration);
if (registrations.size === 0 && byManager.get(key) === registrations) byManager.delete(key);
};
const registration: ArmedFinishNotification = {
stop,
supersedeWhen(accepted) {
superseded = superseded.then(async (prior) => prior || (await accepted));
},
};
registrations.add(registration);
async function watch(): Promise<void> {
while (!controller.signal.aborted) {
const result = await params.wait(controller.signal);
if (controller.signal.aborted || (await superseded) || result.status === "timeout") return;
const parent = getParentAddressFromLabels(
result.final?.labels,
params.childServerId ?? "self",
);
if (
parent?.agentId !== params.callerAgentId ||
parent.serverId !== (params.originServerId ?? "self")
)
return;
if ((await params.agentStorage.get(params.callerAgentId))?.archivedAt) return;
const reason = { idle: "finished", error: "errored", permission: "needs permission" }[
result.status
] as FinishNotificationReason;
const permissionRequest = result.final?.pendingPermissions[0];
await sendPromptToAgent({
agentManager: params.agentManager,
agentStorage: params.agentStorage,
agentId: params.callerAgentId,
prompt: formatFinishNotificationBody({
childAgentId: params.childAgentId,
serverId: params.childServerId,
title: result.final?.title ?? params.childAgentId,
reason,
lastAssistantMessage: result.lastMessage,
permissionRequest,
}),
source: {
kind: "agent-notification",
agentId: params.childAgentId,
title: result.final?.title ?? params.childAgentId,
event: finishNotificationEvents[reason],
},
activeTurnBehavior: "steer",
unarchive: false,
logger: params.logger,
});
if (result.status !== "permission" || !permissionRequest) return;
await params.afterPermission(permissionRequest.id, controller.signal);
}
}
void watch()
.catch((error) => {
if (!controller.signal.aborted)
params.logger.warn(
{ err: error, agentId: params.childAgentId },
"Child finish wait stopped",
);
})
.finally(stop);
return stop;
}
export function setupFinishNotification(params: SetupFinishNotificationParams): () => void {
const {
agentManager,
@@ -499,7 +602,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
armedFinishNotifications.set(agentManager, armedByManager);
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
const armedKey = JSON.stringify([params.childServerId ?? null, childAgentId, callerAgentId]);
const registrations = armedByManager.get(armedKey) ?? new Set<ArmedFinishNotification>();
armedByManager.set(armedKey, registrations);
const previous = [...registrations];
@@ -0,0 +1,323 @@
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import {
DaemonClient,
type FetchWorkspacesEntry,
type WaitForFinishResult,
} from "@getpaseo/client/internal/daemon-client";
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
import type { HostDirectory } from "@getpaseo/protocol/hosts";
import { OWNER_PERMISSIONS } from "../../authorization/index.js";
import type { ConnectedHosts } from "../../connected-hosts/index.js";
import { resolveRequiredProviderModel } from "../mcp-shared.js";
import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js";
export type HostOperation = (
input: Record<string, unknown>,
context: PaseoToolExecutionContext,
) => Promise<PaseoToolResult>;
export interface HostOperations {
createAgent: HostOperation;
createWorkspace: HostOperation;
listWorkspaces: HostOperation;
wait(agentId: string, signal: AbortSignal): Promise<WaitForFinishResult>;
observe(
agentId: string,
handler: (snapshot: AgentSnapshotPayload | null) => void,
signal: AbortSignal,
): Promise<() => Promise<void>>;
}
export interface ToolHosts {
directory(): Promise<HostDirectory>;
use<T>(serverId: string, action: (client: DaemonClient) => Promise<T>): Promise<T>;
}
/** Daemon MCP has the daemon's authority, bounded by the destination's admission of its link. */
export function createToolHosts(hosts: ConnectedHosts): ToolHosts {
return {
directory: () => hosts.list(),
async use(serverId, action) {
const lifetime = new AbortController();
const transportFactory: DaemonTransportFactory = () => {
const opens = new Set<() => void>();
const closes = new Set<(event?: unknown) => void>();
const errors = new Set<(event: unknown) => void>();
const messages = new Set<(data: unknown, binary: boolean) => void>();
let closed = false;
let route: Awaited<ReturnType<ConnectedHosts["openRoute"]>> | undefined;
void hosts
.openRoute({
serverId,
scopeId: randomUUID(),
sourceId: "mcp",
originSessionId: "mcp",
permissions: OWNER_PERMISSIONS,
signal: lifetime.signal,
frame: (data) => {
for (const listener of messages) listener(data, typeof data !== "string");
},
ended: (code, message) => {
for (const listener of errors)
listener(Object.assign(new Error(message), { code, serverId }));
for (const listener of closes) listener();
},
})
.then((opened) => {
route = opened;
if (closed) opened.close();
else for (const listener of opens) listener();
return undefined;
})
.catch((error) => {
for (const listener of errors) listener(error);
for (const listener of closes) listener(error);
});
return {
send: (data) => {
if (!route) throw new Error("Host route is not open");
route.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
},
close: () => {
closed = true;
route?.close();
},
onOpen: (listener) => {
opens.add(listener);
return () => opens.delete(listener);
},
onClose: (listener) => {
closes.add(listener);
return () => closes.delete(listener);
},
onError: (listener) => {
errors.add(listener);
return () => errors.delete(listener);
},
onMessage: (listener) => {
messages.add(listener);
return () => messages.delete(listener);
},
};
};
const client = new DaemonClient({
url: "ws://host-route/ws",
clientId: randomUUID(),
transportFactory,
reconnect: { enabled: false },
});
try {
await client.connect();
return await action(client);
} finally {
lifetime.abort();
await client.close();
}
},
};
}
const remoteCreate = z
.object({
provider: z.string().min(1),
title: z.string().trim().min(1).max(60),
initialPrompt: z.string().min(1),
workspaceId: z.string().min(1),
labels: z.record(z.string(), z.string()).optional(),
notifyOnFinish: z.boolean().optional(),
idempotencyKey: z.string().optional(),
settings: z
.object({
modeId: z.string().optional(),
thinkingOptionId: z.string().optional(),
features: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
})
.strict();
const result = (structuredContent: unknown): PaseoToolResult => ({
content: [],
structuredContent,
});
export function createHostResolver(input: {
serverId: string;
local: HostOperations;
hosts: () => ToolHosts;
callerAgentId?: string;
lockedCwd?: string;
}) {
return {
directory: () => input.hosts().directory(),
async use<T>(
serverId: string | undefined,
action: (host: HostOperations) => Promise<T>,
): Promise<T> {
const destination = serverId ?? input.serverId;
if (destination === input.serverId) return action(input.local);
if (input.lockedCwd)
throw new Error("This caller is restricted to its local working directory");
return input.hosts().use(destination, async (client) =>
action({
async createAgent(args) {
const parsed = remoteCreate.parse(args);
const { provider, model } = resolveRequiredProviderModel(parsed.provider);
const workspace = (await client.fetchWorkspaces({})).entries.find(
(row) => row.id === parsed.workspaceId,
);
if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`);
const labels = {
...parsed.labels,
...(input.callerAgentId
? parentAddressLabels({ agentId: input.callerAgentId, serverId: input.serverId })
: {}),
};
const snapshot = await client.createAgent({
config: {
provider,
model,
cwd: workspace.workspaceDirectory,
title: parsed.title,
modeId: parsed.settings?.modeId,
thinkingOptionId: parsed.settings?.thinkingOptionId,
featureValues: parsed.settings?.features,
},
workspaceId: parsed.workspaceId,
labels,
initialPrompt: parsed.initialPrompt,
idempotencyKey: parsed.idempotencyKey,
});
return result({
agentId: snapshot.id,
type: snapshot.provider,
status: snapshot.status,
cwd: snapshot.cwd,
workspaceId: snapshot.workspaceId,
currentModeId: snapshot.currentModeId,
availableModes: snapshot.availableModes,
});
},
async listWorkspaces(args) {
const { entries } = await client.fetchWorkspaces({
includeBackground: args.includeBackground === true,
});
return result({ workspaces: entries.map(workspaceSummary) });
},
async createWorkspace(args) {
const isolation = z.enum(["local", "worktree"]).parse(args.isolation);
const path = z.string().min(1).parse(args.path);
const response = await client.createWorkspace({
source:
isolation === "local"
? { kind: "directory", path, projectId: args.projectId as string | undefined }
: {
kind: "worktree",
cwd: path,
projectId: args.projectId as string | undefined,
action:
args.mode === "checkout-branch" || args.mode === "checkout-pr"
? "checkout"
: "branch-off",
baseBranch: args.baseBranch as string | undefined,
branchName: args.branchName as string | undefined,
refName: args.branch as string | undefined,
githubPrNumber: args.prNumber as number | undefined,
worktreeSlug: args.worktreeSlug as string | undefined,
},
title: args.title as string | undefined,
background: args.background as boolean | undefined,
});
if (response.error || !response.workspace)
throw new Error(response.error ?? "Workspace creation failed");
return result(workspaceSummary(response.workspace));
},
async observe(agentId, handler, signal) {
const observation = client.observeTimeline([agentId], { signal });
const refresh = async () => handler((await client.fetchAgent(agentId))?.agent ?? null);
const unsubscribe = observation.subscribe({
snapshot: () => {},
update: (message) => {
if (message.type !== "agent_stream" || message.payload.agentId !== agentId) return;
const event = message.payload.event;
if (
event.type === "permission_resolved" ||
event.type === "turn_started" ||
event.type === "turn_completed" ||
event.type === "turn_canceled" ||
event.type === "turn_failed"
)
void refresh().catch(() => handler(null));
},
error: () => handler(null),
});
try {
await observation.ready;
await refresh();
} catch (error) {
unsubscribe();
await observation.release();
throw error;
}
return async () => {
unsubscribe();
await observation.release();
};
},
wait: (agentId, signal) => {
const close = () => void client.close();
signal.addEventListener("abort", close, { once: true });
return client
.waitForFinish(agentId, 0)
.finally(() => signal.removeEventListener("abort", close));
},
}),
);
},
};
}
function workspaceSummary(workspace: FetchWorkspacesEntry) {
return {
workspaceId: workspace.id,
projectId: workspace.projectId,
cwd: workspace.workspaceDirectory,
isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local",
kind: workspace.workspaceKind === "checkout" ? "local_checkout" : workspace.workspaceKind,
title: workspace.title,
background: workspace.background,
};
}
/** Wait pauses at permissions; observe only until that request clears before re-arming it. */
export async function waitForPermissionChange(
host: HostOperations,
agentId: string,
requestId: string,
signal: AbortSignal,
): Promise<void> {
signal.throwIfAborted();
let changed!: () => void;
let failed!: (error: unknown) => void;
const ready = new Promise<void>((resolve, reject) => {
changed = resolve;
failed = reject;
});
const abort = () => failed(signal.reason ?? new Error("Finish wait stopped"));
signal.addEventListener("abort", abort, { once: true });
let release: (() => Promise<void>) | undefined;
try {
release = await host.observe(
agentId,
(snapshot) => {
if (!snapshot) failed(new Error(`Agent ${agentId} is unavailable`));
else if (!snapshot.pendingPermissions.some((request) => request.id === requestId))
changed();
},
signal,
);
await ready;
} finally {
signal.removeEventListener("abort", abort);
await release?.();
}
}
@@ -1,3 +1,9 @@
import {
createHostResolver,
waitForPermissionChange,
type HostOperation,
type ToolHosts,
} from "../host-operations/index.js";
import { AgentDirectory } from "../directory.js";
import { stat } from "node:fs/promises";
import { z } from "zod";
@@ -72,6 +78,7 @@ import {
type PromptDispatchDisposition,
sendPromptToAgent,
setupFinishNotification,
setupFinishWait,
waitForAgentRunStartWithTimeout,
} from "../agent-prompt.js";
import { respondToAgentPermission } from "../permission-response.js";
@@ -110,6 +117,8 @@ import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-confi
import { isPaseoToolEnabled } from "../paseo-tool-policy.js";
export interface PaseoToolHostDependencies {
serverId?: string;
hosts?: () => ToolHosts;
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager?: TerminalManager | null;
@@ -165,6 +174,10 @@ export interface PaseoToolHostDependencies {
logger: Logger;
}
function settledStatus(status: string): "error" | "idle" {
return status === "error" ? "error" : "idle";
}
function parseTimestamp(value: string | null | undefined): number {
if (!value) {
return 0;
@@ -685,7 +698,58 @@ function createToolCatalog(
return result.data;
};
const localHostOperations = new Map<string, HostOperation>();
const tools = new Map<string, PaseoToolDefinition>();
const hostTools = new Map([
["create_agent", "createAgent"],
["create_workspace", "createWorkspace"],
["list_workspaces", "listWorkspaces"],
] as const);
const hostResolver = createHostResolver({
serverId: options.serverId ?? "self",
callerAgentId,
lockedCwd: callerContext?.lockedCwd,
hosts: () => {
if (!options.hosts) throw new Error("Connected hosts are not configured");
return options.hosts();
},
local: {
createAgent: (args, context) => localHostOperations.get("create_agent")!(args, context),
createWorkspace: (args, context) =>
localHostOperations.get("create_workspace")!(args, context),
listWorkspaces: (args, context) => localHostOperations.get("list_workspaces")!(args, context),
async observe(agentId, handler, signal) {
const unsubscribe = agentManager.subscribe(
(event) => {
if (event.type === "agent_state") handler(toAgentPayload(event.agent));
},
{ agentId, replayState: true },
);
const stop = () => unsubscribe();
signal.addEventListener("abort", stop, { once: true });
return async () => {
signal.removeEventListener("abort", stop);
unsubscribe();
};
},
async wait(agentId, signal) {
const settled = await agentManager.waitForAgentEvent(agentId, {
signal,
waitForActive: true,
});
const snapshot = agentManager.getAgent(agentId);
const final = snapshot
? await serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger)
: null;
return {
status: settled.permission ? "permission" : settledStatus(settled.status),
final,
lastMessage: settled.lastMessage,
error: final?.lastError ?? null,
};
},
},
});
const registerTool = (
name: string,
config: PaseoToolConfig,
@@ -695,6 +759,73 @@ function createToolCatalog(
if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) {
return;
}
const operation = hostTools.get(
name as "create_agent" | "create_workspace" | "list_workspaces",
);
if (operation) {
localHostOperations.set(name, handler);
const original = config.inputSchema;
const selector = {
serverId: z
.string()
.min(1)
.optional()
.describe("Destination server ID from list_hosts; omit for this host."),
};
config = {
...config,
inputSchema:
original instanceof z.ZodObject
? original.extend(selector)
: { ...(original as z.ZodRawShape), ...selector },
};
handler = async ({ serverId, ...input }, context) => {
const destination = serverId ?? options.serverId;
try {
const response = await hostResolver.use(serverId, (host) =>
host[operation](input, context),
);
if (name === "create_agent" && callerAgentId && input.notifyOnFinish !== false) {
const created = response.structuredContent as { agentId: string };
setupFinishWait({
agentManager,
agentStorage,
originServerId: options.serverId,
childServerId: destination,
childAgentId: created.agentId,
callerAgentId,
logger: childLogger,
wait: (signal) =>
hostResolver.use(destination, (host) => host.wait(created.agentId, signal)),
afterPermission: (requestId, signal) =>
hostResolver.use(destination, (host) =>
waitForPermissionChange(host, created.agentId, requestId, signal),
),
});
}
return {
...response,
structuredContent: {
...(response.structuredContent as object),
...(destination ? { serverId: destination } : {}),
},
};
} catch (error) {
if (!options.serverId) throw error;
const failure = {
serverId: destination,
error: error instanceof Error ? error.message : String(error),
...(error instanceof Error && "code" in error ? { code: error.code } : {}),
...(error instanceof Error && "outcome" in error ? { outcome: error.outcome } : {}),
};
return {
isError: true,
content: [{ type: "text", text: JSON.stringify(failure) }],
structuredContent: failure,
};
}
};
}
tools.set(name, {
name,
title: config.title,
@@ -1332,6 +1463,19 @@ function createToolCatalog(
});
}
registerTool(
"list_hosts",
{
title: "List hosts",
description: "Discover this host and registered destination hosts for fleet tools.",
inputSchema: {},
},
async () => ({
content: [],
structuredContent: ensureValidJson(await hostResolver.directory()),
}),
);
registerTool(
"create_workspace",
{
@@ -1593,10 +1737,15 @@ function createToolCatalog(
workspaceId: resolvedArgs.workspaceId,
thinking: parsedArgs.settings?.thinkingOptionId,
features: parsedArgs.settings?.features,
labels: parsedArgs.labels,
labels: {
...parsedArgs.labels,
...(callerAgentId && !resolvedArgs.detached && options.serverId
? { "paseo.parent-server-id": options.serverId }
: {}),
},
mode: parsedArgs.settings?.modeId,
promptFailure: "throw",
notifyOnFinish,
notifyOnFinish: false,
detached: resolvedArgs.detached,
callerAgentId,
callerContext,
@@ -2003,6 +2152,7 @@ function createToolCatalog(
agentManager,
agentStorage,
childAgentId: agentId,
childServerId: options.serverId,
callerAgentId,
waitForTurnStart: false,
dispatchResult,
@@ -2023,6 +2173,7 @@ function createToolCatalog(
agentManager,
agentStorage,
childAgentId: agentId,
childServerId: options.serverId,
callerAgentId,
waitForTurnStart: true,
logger: childLogger,
@@ -6,6 +6,14 @@ 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.link.open.request": null,
"hosts.route.open.request": null,
"hosts.route.frame.request": null,
"hosts.route.close.request": null,
abort_request: "workspace.write",
"agent.config.apply.request": ["workspace.write", "hub.execute"],
"agent.detach.request": "workspace.write",
@@ -216,6 +224,15 @@ 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.link.open.response": null,
"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",
+5
View File
@@ -1393,6 +1393,11 @@ export async function createPaseoDaemon(
const createAgentToolHostDependencies = (
runtime: PaseoToolRuntimeContext,
): PaseoToolHostDependencies => ({
serverId,
hosts: () => {
if (!wsServer) throw new Error("Daemon is not accepting host operations");
return wsServer.toolHosts();
},
agentManager,
agentStorage,
terminalManager,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,539 @@
export { DaemonLinkEndpoint, VirtualHostSocket } from "./link.js";
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,
createHostChannelTransport,
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 {
client: DaemonClient;
accepted: boolean;
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);
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.",
);
}
if (this.rows.length >= 128)
throw new HostError("resource_limit", "Remove a host before adding another (limit 128)");
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 stopped = false;
const client = createHostClient(
row.connection,
{
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000,
},
this.budget,
);
const live: Live = {
client,
accepted: false,
stop: () => {
stopped = true;
void client.close();
},
};
this.live.set(row.registrationId, live);
const accept = async () => {
try {
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 || info.features?.hostMultiplexing !== 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)));
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
Object.assign(row, next);
live.accepted = true;
this.changed();
});
} catch (error) {
if (stopped) return;
row.status = "error";
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
code: "host_offline",
message: "Host connection failed",
};
row.nextRetryAt = null;
this.changed();
await client.close();
}
};
client.subscribeConnectionStatus((state) => {
if (stopped || row.status === "error") return;
live.accepted = false;
if (state.status === "connected") {
void accept();
return;
}
for (const route of this.routes)
if (route.registrationId === row.registrationId) route.close();
row.status = state.status === "connecting" ? "connecting" : "offline";
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
row.error =
state.status === "disconnected"
? (connectionFailure(
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
code: state.failureCode,
}),
client.authFailureReason !== null,
) ?? {
code: "host_offline",
message: "Cannot reach host. Check the connection; retrying automatically.",
})
: null;
if (row.error?.code === "authentication_failed") {
row.status = "error";
row.nextRetryAt = null;
client.setReconnectEnabled(false);
void client.close();
}
this.changed();
});
void client.connect().catch(() => {});
}
async openRoute(input: {
serverId: string;
scopeId: string;
sourceId: string;
originSessionId: string;
permissions: readonly DaemonPermission[];
signal: AbortSignal;
frame(data: string | Uint8Array): void;
ended(code: string, message: string): void;
}): Promise<{ send(data: string | Uint8Array): 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 live = this.live.get(row.registrationId);
const client = live?.client;
if (!live?.accepted || !client?.isConnected)
throw new HostError("host_offline", "Host is offline", input.serverId);
const transport = createHostChannelTransport(
client,
input.serverId,
randomUUID(),
null,
true,
)({ url: "ws://host-link/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 ? (data as Uint8Array) : String(data));
});
await ready;
if (closed) throw new HostError("host_offline", "Host closed during connection");
return {
close: () => route.close(),
send: (data) => {
const binary = typeof data !== "string";
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 string) as WSHelloMessage;
if (hello.type !== "hello") throw new HostError("invalid_message", "Expected hello");
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: undefined,
}),
);
helloSent = true;
return;
}
if (!verified) throw new HostError("host_offline", "Host identity has not been verified");
try {
transport.send(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,210 @@
import type { SessionDelivery } from "../session/owned-subscriptions/index.js";
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import { encodeHostRouteFrame, type HostRouteFrame } from "@getpaseo/protocol/binary-frames/index";
import type {
DaemonPermission,
SessionInboundMessage,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
/** A logical socket has no network resources or authority of its own. */
export class VirtualHostSocket extends EventEmitter {
readyState = 1;
constructor(
private readonly output: (data: string | Uint8Array) => Promise<void>,
private readonly ended: (code: number, reason: string) => void,
private readonly buffered: () => number,
private readonly transformHello: (data: string) => string,
) {
super();
}
get bufferedAmount(): number {
return this.buffered();
}
private helloPending = true;
feed(data: string | Uint8Array): void {
if (this.readyState !== 1) return;
if (this.helloPending) {
if (typeof data !== "string") {
this.close(1008, "Expected hello");
return;
}
try {
data = this.transformHello(data);
} catch {
this.close(1008, "Invalid hello");
return;
}
this.helloPending = false;
}
this.emit("message", typeof data === "string" ? data : Buffer.from(data));
}
async send(data: string | Uint8Array | ArrayBuffer): Promise<void> {
if (this.readyState !== 1) throw new Error("Host channel closed");
await this.output(
typeof data === "string" || data instanceof Uint8Array ? data : new Uint8Array(data),
);
}
close(code = 1000, reason = "Host channel closed"): void {
if (this.readyState !== 1) return;
this.readyState = 3;
this.ended(code, reason);
this.emit("close", 1000, "Host channel closed");
}
}
/** One authenticated physical source owns all its channels and their admission ceiling. */
export class DaemonLinkEndpoint {
private readonly channels = new Map<string, { scopeId: string; socket: VirtualHostSocket }>();
private closed = false;
constructor(
private readonly parent: {
identity: string;
permissions(): readonly DaemonPermission[];
routed(): boolean;
bufferedAmount(): number | null;
delivery: SessionDelivery;
admit(socket: VirtualHostSocket, permissions: readonly DaemonPermission[]): Promise<void>;
},
) {}
handles(message: SessionInboundMessage): boolean {
return (
message.type === "hosts.link.open.request" ||
(message.type === "hosts.route.frame.request" && this.channels.has(message.routeId)) ||
(message.type === "hosts.route.close.request" &&
((!!message.routeId && this.channels.has(message.routeId)) ||
[...this.channels.values()].some((channel) => channel.scopeId === message.scopeId)))
);
}
async dispatch(message: SessionInboundMessage): Promise<void> {
if (message.type === "hosts.link.open.request") {
const error = (code: string, text: string) =>
this.parent.delivery.reply({
type: "rpc_error",
payload: {
requestId: message.requestId,
requestType: message.type,
code,
error: text,
},
});
if (this.closed || this.parent.routed()) {
error("unsupported_capability", "Connected hosts support one hop");
return;
}
if (this.channels.size >= 128) {
error("resource_limit", "Too many host channels");
return;
}
let socket: VirtualHostSocket;
const owner = this.parent.delivery.begin("host-link", undefined, () => {
this.channels.delete(owner.id);
socket?.close();
});
const id = owner.id;
// Capture actual authenticated authority. The caller hello can only reduce it.
const ceiling = [...this.parent.permissions()];
socket = new VirtualHostSocket(
async (data) => {
const output: SessionOutboundMessage | null =
typeof data === "string"
? {
type: "hosts.route.frame",
payload: { subscriptionId: id, data, binary: false },
}
: null;
const frame = typeof data === "string" ? null : encodeHostRouteFrame(id, data);
const bytes = output
? Buffer.byteLength(JSON.stringify({ type: "session", message: output }))
: frame!.byteLength;
const buffered = this.parent.bufferedAmount();
// Account for the exact escaped outer frame, retaining control-frame headroom.
if (buffered === null || buffered + bytes > 64 * 1024 * 1024 - 65536) {
socket.close(1009, "Host channel output limit reached");
throw new Error("Host channel output limit reached");
}
try {
if (output) await owner.emitAndWait(output);
else await owner.emitBinaryAndWait(frame!);
} catch (failure) {
socket.close();
throw failure;
}
},
(code, reason) => {
this.channels.delete(id);
let failure = "host_offline";
if (code === 1009) failure = "resource_limit";
if (code === 1008) failure = "permission_denied";
owner.emit({
type: "hosts.route.closed",
payload: {
subscriptionId: id,
code: failure,
message: reason,
},
});
void owner.release();
},
() => this.parent.bufferedAmount() ?? 0,
(data) => {
const hello = JSON.parse(data);
if (hello.type !== "hello" || typeof hello.clientId !== "string")
throw new Error("Expected hello");
return JSON.stringify({
...hello,
auth: undefined,
routed: true,
clientId: `link-${createHash("sha256")
.update(JSON.stringify([this.parent.identity, hello.clientId]))
.digest("hex")}`,
});
},
);
this.channels.set(id, { scopeId: message.channelId, socket });
try {
await this.parent.admit(socket, ceiling);
if (this.closed || socket.readyState !== 1) {
socket.close();
return;
}
this.parent.delivery.reply({
type: "hosts.link.open.response",
payload: { requestId: message.requestId, routeId: id },
});
} catch {
socket.close();
error("host_offline", "Could not admit host channel");
}
return;
}
if (message.type === "hosts.route.frame.request" && this.channels.has(message.routeId)) {
this.channels.get(message.routeId)!.socket.feed(message.data);
return;
}
if (message.type === "hosts.route.close.request") {
for (const [id, channel] of this.channels) {
if (message.routeId === id || (message.scopeId && message.scopeId === channel.scopeId))
channel.socket.close();
}
}
return;
}
binary(frame: HostRouteFrame): boolean {
const socket = this.channels.get(frame.routeId)?.socket;
if (!socket) return false;
socket.feed(frame.payload);
return true;
}
close(reason: "host_offline" | "permission_denied" = "host_offline"): void {
if (this.closed) return;
this.closed = true;
for (const { socket } of this.channels.values())
socket.close(
reason === "permission_denied" ? 1008 : 1000,
"Physical link authority or connection changed",
);
this.channels.clear();
}
}
@@ -0,0 +1,252 @@
import { encodeHostRouteFrame, type HostRouteFrame } from "@getpaseo/protocol/binary-frames/index";
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 | Uint8Array): 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,
) {}
binary(frame: HostRouteFrame, source: object): void {
const route = this.routes.get(frame.routeId);
if (!route || route.source !== source) return;
try {
route.send(frame.payload);
} catch {
route.fail("resource_limit", "Host input buffer limit reached");
}
}
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);
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) => {
const output: SessionOutboundMessage | null =
typeof data === "string"
? {
type: "hosts.route.frame",
payload: { subscriptionId: owner.id, data, binary: false },
}
: null;
const binary = typeof data === "string" ? null : encodeHostRouteFrame(owner.id, data);
const size = output
? Buffer.byteLength(JSON.stringify({ type: "session", message: output }))
: binary!.byteLength;
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;
}
if (output) owner.emit(output);
else owner.emitBinary(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,152 @@ 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;
let wire: Awaited<ReturnType<typeof relayWire>> | 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);
wire = await relayWire(port);
const connection = new URL(`relay://127.0.0.1:${wire.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");
const admittedConnections = wire.connections();
expect(wire.active()).toBe(1);
remote = await api.hosts.client(serverId);
sibling = await api.hosts.client(serverId);
expect(wire.connections()).toBe(admittedConnections);
expect(wire.active()).toBe(1);
expect((await remote.workspaces.list()).entries).toEqual([]);
const observed = await remote.workspaces.list({ subscribe: {} });
let snapshots = 0;
observed.subscription.subscribe({
snapshot: () => {
snapshots++;
},
update() {},
});
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);
expect(wire.connections()).toBe(admittedConnections);
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("DAEMON_CONNECTION_LOST");
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);
expect(wire.active()).toBe(1);
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 wire?.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 +1085,112 @@ 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 detachBinary = origin.subscribeHostFrames((routed) => {
if (routed.routeId !== routeId) return;
const frame = decodeTerminalStreamFrame(routed.payload);
if (frame?.opcode === TerminalStreamOpcode.Output)
output += new TextDecoder().decode(frame.payload);
});
const detach = origin.subscribeRawMessages((message) => {
if (message.type !== "hosts.route.frame" || message.payload.subscriptionId !== routeId) return;
const frame = WSOutboundMessageSchema.parse(JSON.parse(message.payload.data));
if (frame.type === "session") messages.push(frame.message);
});
const close = () => {
detach();
detachBinary();
origin.closeHostRoute(routeId);
};
try {
origin.sendHostFrame(
routeId,
JSON.stringify({
type: "hello",
clientId: "relay-binary",
clientType: "cli",
protocolVersion: 1,
}),
);
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" },
}),
);
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,
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Input, slot, payload }),
),
};
} catch (error) {
close();
throw error;
}
}
async function relayWire(relayPort: number) {
const sockets = new Set<net.Socket>();
let connections = 0;
let active = 0;
const server = net.createServer((source) => {
const target = net.connect(relayPort, "127.0.0.1");
sockets.add(source);
sockets.add(target);
let counted = false;
target.on("connect", () => {
counted = true;
connections++;
active++;
});
target.on("error", () => source.destroy());
source.on("error", () => target.destroy());
target.on("close", () => {
if (counted) active--;
sockets.delete(target);
source.destroy();
});
source.on("close", () => {
sockets.delete(source);
target.destroy();
});
source.pipe(target);
target.pipe(source);
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing relay wire port");
return {
port: address.port,
connections: () => connections,
active: () => active,
close: async () => {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
@@ -288,6 +288,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
});
interface SessionForTestOptions {
sendToSource?: SessionOptions["onMessageToSource"];
clientId?: string;
permissions?: readonly DaemonPermission[];
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
@@ -441,6 +442,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
daemonRuntimeConfig: options.daemonRuntimeConfig,
permissions: options.permissions ?? OWNER_PERMISSIONS,
};
if (options.sendToSource) sessionOptions.onMessageToSource = options.sendToSource;
return new Session(sessionOptions);
}
@@ -5814,3 +5816,40 @@ test("provider snapshots preserve versionless visibility while capabilities upda
]);
expect(references.compactSnapshot!.entries[0]!.modes![0]!.icon).toBe("ShieldCheck");
});
test("a failing direct source observes fire-and-forget file replies without unhandled rejections", async () => {
const root = mkdtempSync(join(tmpdir(), "session-send-failure-"));
writeFileSync(join(root, "readme.txt"), "source reply");
let attempts = 0;
const unhandled: unknown[] = [];
const collect = (error: unknown) => unhandled.push(error);
process.on("unhandledRejection", collect);
const session = createSessionForTest({
sendToSource: async () => {
attempts++;
throw new Error("Physical source closed during send");
},
});
try {
const source = {};
session.updateClientCapabilities(null, source);
await session.handleMessage(
{
type: "file_explorer_request",
requestId: "direct-file",
cwd: root,
path: "readme.txt",
mode: "file",
acceptBinary: false,
},
source,
);
await new Promise<void>((resolve) => setTimeout(resolve, 30));
expect(attempts).toBeGreaterThan(0);
expect(unhandled).toEqual([]);
} finally {
process.off("unhandledRejection", collect);
await session.cleanup();
rmSync(root, { recursive: true, force: true });
}
});
+87 -13
View File
@@ -1,4 +1,6 @@
import { AgentDirectory } from "./agent/directory.js";
import { ConnectedHostSession } from "./connected-hosts/index.js";
import type { ConnectedHosts } from "./connected-hosts/index.js";
import { searchTimeline } from "./agent/chat-search/index.js";
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities";
@@ -442,16 +444,20 @@ const nodeSessionFileSystem: SessionFileSystem = {
type AgentMcpTransportFactory = () => Promise<unknown>;
export interface SessionOptions {
connectedHosts?: ConnectedHosts;
sourceId?: string;
routed?: boolean;
browserToolsBroker?: BrowserToolsBroker | null;
clientId: string;
permissions: readonly DaemonPermission[];
appVersion?: string | null;
clientCapabilities?: Record<string, unknown> | null;
onMessage: (msg: SessionOutboundMessage) => void;
onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void;
onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void | Promise<void>;
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;
@@ -674,17 +680,31 @@ 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;
if (this.onMessageToSource) this.onMessageToSource(source, message);
else this.onMessage(message);
if (this.onMessageToSource) return this.onMessageToSource(source, message);
this.onMessage(message);
},
(source, frame) => {
if (this.onBinaryMessageToSource) {
void this.onBinaryMessageToSource(source, frame).catch((error) =>
this.sessionLogger.warn({ err: error }, "Failed to emit binary frame"),
);
return this.onBinaryMessageToSource(source, frame);
} else this.emitBinary(frame);
},
(source, message) => this.workspaceSetupMessageForClient(message, source),
@@ -706,7 +726,7 @@ export class Session {
private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onMessageToSource:
| ((source: object, msg: SessionOutboundMessage) => void)
| ((source: object, msg: SessionOutboundMessage) => void | Promise<void>)
| null;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
private readonly onBinaryMessageToSource:
@@ -864,13 +884,20 @@ 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.onMessageToSource = onMessageToSource
? (source, message) => this.observeSourceSend(() => onMessageToSource(source, message))
: null;
this.onBinaryMessage = onBinaryMessage ?? null;
this.onBinaryMessageToSource = onBinaryMessageToSource ?? null;
this.onBinaryMessageToSource = onBinaryMessageToSource
? (source, frame) => this.observeSourceSend(() => onBinaryMessageToSource(source, frame))
: null;
this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0);
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
@@ -2173,7 +2200,7 @@ export class Session {
requestId,
requestType: msg.type,
error: `Session is not authorized for ${msg.type}`,
code: "access_denied",
code: msg.type.startsWith("hosts.") ? "permission_denied" : "access_denied",
},
});
}
@@ -2220,6 +2247,7 @@ export class Session {
public setPermissions(permissions: readonly DaemonPermission[]): void {
this.authorization.replacePermissions(permissions);
this.connectedHostSession?.revoke();
if (!this.authorization.allowsPermission("workspace.write")) {
void this.delivery
.releaseFamily("browser-host")
@@ -2374,11 +2402,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) ??
@@ -2396,6 +2423,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) ??
@@ -3177,6 +3215,10 @@ export class Session {
}
public async handleBinaryFrame(binaryFrame: BinaryFrame, source: object): Promise<void> {
if (binaryFrame.kind === "host_route") {
this.connectedHostSession?.binary(binaryFrame.frame, source);
return;
}
if (!this.authorization.allowsPermission("workspace.write")) {
return;
}
@@ -8617,6 +8659,20 @@ export class Session {
this.emitBinary(frame);
}
private observeSourceSend(send: () => void | Promise<void>): Promise<void> {
let pending: Promise<void>;
try {
pending = Promise.resolve(send());
} catch (error) {
pending = Promise.reject(error);
}
// Legacy producers fire and forget; owned streams await the same failure.
void pending.catch((error) =>
this.sessionLogger.warn({ err: error }, "Failed to emit source frame"),
);
return pending;
}
private emitForSource(msg: SessionOutboundMessage, source?: object): void {
if (source && this.onMessageToSource) {
msg = this.delivery.authorizeReply(msg, source);
@@ -8765,3 +8821,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";
}
@@ -169,3 +169,40 @@ test("failed teardown still closes the delivery and rejects late work", async ()
delivery.request(source, { type: "ping", requestId: "late" }, async () => {}),
).rejects.toThrow("Session delivery is closed");
});
test("owned send completion retains source proofs and reports physical failures to awaiting producers", async () => {
const source = {};
const failure = new Error("Physical send failed");
const delivery = new SessionDelivery(
async (socket, message) => {
expect(socket).toBe(source);
expect(delivery.permits(socket, message)).toBe(true);
throw failure;
},
async (socket, frame) => {
expect(socket).toBe(source);
expect(delivery.permitsBinary(socket, frame)).toBe(true);
throw failure;
},
);
delivery.attach(source, true);
await delivery.request(
source,
{ type: "hosts.link.open.request", requestId: "open", channelId: "scope" },
async () => {
const owner = delivery.begin("host-link", undefined, () => {});
await expect(
owner.emitAndWait({
type: "hosts.route.frame",
payload: { subscriptionId: owner.id, data: "text", binary: false },
}),
).rejects.toBe(failure);
await expect(owner.emitBinaryAndWait(new Uint8Array([0, 255]))).rejects.toBe(failure);
await owner.release();
await expect(owner.emitBinaryAndWait(new Uint8Array([0]))).rejects.toThrow(
"Subscription source is closed",
);
},
);
await delivery.close();
});
@@ -51,6 +51,8 @@ export interface OwnedSubscription {
readonly signal: AbortSignal;
emit(message: SessionOutboundMessage): void;
emitBinary(frame: Uint8Array): void;
emitAndWait(message: SessionOutboundMessage): Promise<void>;
emitBinaryAndWait(frame: Uint8Array): Promise<void>;
release(): Promise<void>;
}
@@ -67,8 +69,14 @@ export class SessionDelivery {
private closing?: Promise<void>;
constructor(
private readonly send: (source: object, message: SessionOutboundMessage) => void,
private readonly sendBinary: (source: object, frame: Uint8Array) => void = () => {},
private readonly send: (
source: object,
message: SessionOutboundMessage,
) => void | Promise<void>,
private readonly sendBinary: (
source: object,
frame: Uint8Array,
) => void | Promise<void> = () => {},
private readonly project: (
source: object,
message: SessionOutboundMessage,
@@ -319,7 +327,18 @@ export class SessionDelivery {
emitBinary: (frame) => {
if (!owner.active || !source.active) return;
this.proofs.set(frame, owner);
this.sendBinary(source.socket, frame);
void Promise.resolve(this.sendBinary(source.socket, frame)).catch(() => {});
},
emitAndWait: async (message) => {
if (!owner.active || !source.active) throw new Error("Subscription source is closed");
const projected = this.project(source.socket, withSubscriptionId(message, responseId));
this.proofs.set(projected, owner);
await this.send(source.socket, projected);
},
emitBinaryAndWait: async (frame) => {
if (!owner.active || !source.active) throw new Error("Subscription source is closed");
this.proofs.set(frame, owner);
await this.sendBinary(source.socket, frame);
},
release: () => this.releaseOwner(owner),
};
@@ -414,7 +433,7 @@ export class SessionDelivery {
private sendOwned(owner: DeliveryOwner, message: SessionOutboundMessage): void {
const projected = this.project(owner.source.socket, message);
this.proofs.set(projected, owner);
this.send(owner.source.socket, projected);
void Promise.resolve(this.send(owner.source.socket, projected)).catch(() => {});
}
private releaseOwner(owner: RetainedOwner): Promise<void> {
@@ -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", () => {
+159 -11
View File
@@ -1,3 +1,7 @@
import { createToolHosts } from "./agent/host-operations/index.js";
import { HOST_ROUTE_OPCODE } from "@getpaseo/protocol/binary-frames/index";
import { DaemonLinkEndpoint, VirtualHostSocket } from "./connected-hosts/index.js";
import { ConnectedHosts } from "./connected-hosts/index.js";
import { stat } from "node:fs/promises";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import { CreationService } from "./creation/index.js";
@@ -437,6 +441,8 @@ export interface WebSocketLike {
}
interface SessionConnectionBase {
hubExecutionAgents?: HubExecutionAgents;
permissionCeiling?: readonly DaemonPermission[];
session: Session;
principalId: string;
sessionKey: string;
@@ -460,13 +466,14 @@ interface PluginSessionConnection extends SessionConnectionBase {
type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection;
interface SocketSessionOptions {
sourceId?: string;
clientId: string;
appVersion: string | null;
clientCapabilities: Record<string, unknown> | null;
permissions: readonly DaemonPermission[];
connectionLogger: pino.Logger;
onMessage: (message: SessionOutboundMessage) => void;
onMessageToSource?: (source: object, message: SessionOutboundMessage) => void;
onMessageToSource?: (source: object, message: SessionOutboundMessage) => void | Promise<void>;
onBinaryMessage?: (frame: Uint8Array) => void;
onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise<void>;
getTransportBufferedAmount?: (source?: object) => number | null;
@@ -527,6 +534,7 @@ function requireWebSocketServices(params: {
export class VoiceAssistantWebSocketServer {
private readonly logger: pino.Logger;
private readonly wss: WebSocketServer;
private readonly hostLinks = new Map<WebSocketLike, DaemonLinkEndpoint>();
private readonly pendingConnections: Map<WebSocketLike, PendingConnection> = new Map();
private readonly sessions: Map<WebSocketLike, SessionConnection> = new Map();
private readonly socketIdentities: Map<WebSocketLike, WebSocketConnectionIdentity> = new Map();
@@ -550,6 +558,11 @@ export class VoiceAssistantWebSocketServer {
private readonly workspaceAutoName: WorkspaceAutoName;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string;
private readonly connectedHosts: ConnectedHosts;
toolHosts() {
return createToolHosts(this.connectedHosts);
}
private readonly passwordHash: string | undefined;
private readonly credentialSource: DaemonAuthConfig | undefined;
private readonly worktreesRoot: string | undefined;
@@ -701,6 +714,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,6 +1061,12 @@ export class VoiceAssistantWebSocketServer {
principalId: string,
permissions: readonly DaemonPermission[],
): void {
for (const [socket, link] of this.hostLinks) {
if (this.sessions.get(socket)?.principalId === principalId) {
link.close("permission_denied");
this.hostLinks.delete(socket);
}
}
for (const pending of this.pendingConnections.values()) {
if (pending.admission?.principalId === principalId) {
pending.admission = { ...pending.admission, permissions };
@@ -1054,7 +1074,11 @@ export class VoiceAssistantWebSocketServer {
}
for (const connection of new Set(this.externalSessionsByKey.values())) {
if (connection.principalId === principalId) {
connection.session.setPermissions(permissions);
connection.session.setPermissions(
connection.permissionCeiling
? permissions.filter((permission) => connection.permissionCeiling!.includes(permission))
: permissions,
);
}
}
}
@@ -1071,6 +1095,7 @@ export class VoiceAssistantWebSocketServer {
public async close(): Promise<void> {
this.prepareForShutdown();
await this.connectedHosts.close();
this.unsubscribeSpeechReadiness?.();
this.unsubscribeSpeechReadiness = null;
this.unsubscribeDaemonConfigChange?.();
@@ -1159,6 +1184,20 @@ export class VoiceAssistantWebSocketServer {
message: WSOutboundMessage,
reportRejection = false,
): void {
const prepared = this.prepareJsonDelivery(sockets, message, reportRejection);
if (!prepared) return;
for (const ws of prepared.sockets) {
this.sendFrameToClient(ws, prepared.payload, prepared.bytes, () => {
this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount);
});
}
}
private prepareJsonDelivery(
sockets: Iterable<WebSocketLike>,
message: WSOutboundMessage,
reportRejection: boolean,
): { sockets: WebSocketLike[]; payload: string; bytes: number } | null {
const writableSockets = [...sockets].filter((ws) => {
if (
message.type === "session" &&
@@ -1180,7 +1219,7 @@ export class VoiceAssistantWebSocketServer {
return this.ensureOutboundCapacity(ws, 0);
});
if (writableSockets.length === 0) {
return;
return null;
}
let payload: string;
@@ -1188,14 +1227,30 @@ export class VoiceAssistantWebSocketServer {
payload = JSON.stringify(message);
} catch (err) {
this.logger.warn({ err }, "ws_serialize_failed");
return;
return null;
}
const payloadBytes = outboundFrameByteLength(payload);
for (const ws of writableSockets) {
this.sendFrameToClient(ws, payload, payloadBytes, () => {
this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount);
return { sockets: writableSockets, payload, bytes: outboundFrameByteLength(payload) };
}
private async sendMessageToClientAndWait(
ws: WebSocketLike,
message: WSOutboundMessage,
): Promise<void> {
const prepared = this.prepareJsonDelivery([ws], message, true);
if (!prepared) throw new Error("Source message delivery unavailable");
try {
const sent = await sendBoundedPhysicalFrameAndWait({
socket: ws,
frame: prepared.payload,
frameBytes: prepared.bytes,
onHighWater: () => this.closeAtOutboundHighWater(ws),
});
if (!sent) throw new Error("Source is closed");
this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount);
} catch (err) {
this.logger.warn({ err }, "ws_send_failed");
throw err;
}
}
@@ -1394,6 +1449,10 @@ export class VoiceAssistantWebSocketServer {
appVersion,
clientCapabilities,
permissions: admission.permissions,
sourceId:
lifecycle.kind === "ephemeral-plugin"
? `plugin:${lifecycle.pluginId}`
: admission.principalId,
connectionLogger,
onMessage: (msg) => {
if (!connection) {
@@ -1401,11 +1460,11 @@ export class VoiceAssistantWebSocketServer {
}
this.sendToConnection(connection, wrapSessionMessage(msg));
},
onMessageToSource: (source, msg) => {
onMessageToSource: async (source, msg) => {
if (!connection || !connection.sockets.has(source as WebSocketLike)) {
return;
}
this.sendToClient(source as WebSocketLike, wrapSessionMessage(msg));
await this.sendMessageToClientAndWait(source as WebSocketLike, wrapSessionMessage(msg));
},
onBinaryMessage: (frame) => {
if (!connection) {
@@ -1446,6 +1505,7 @@ export class VoiceAssistantWebSocketServer {
const base: SessionConnectionBase = {
session,
principalId: admission.principalId,
hubExecutionAgents: admission.hubExecutionAgents,
sessionKey: sessionConnectionKey(admission.principalId, clientId),
clientId,
appVersion,
@@ -1463,6 +1523,17 @@ 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 (socket instanceof VirtualHostSocket) continue;
if (typeof socket.bufferedAmount !== "number") return null;
total += socket.bufferedAmount;
}
return total;
},
sourceId: options.sourceId,
browserToolsBroker: this.browserToolsBroker,
clientId: options.clientId,
appVersion: options.appVersion,
@@ -1616,8 +1687,16 @@ export class VoiceAssistantWebSocketServer {
}
this.clearPendingConnection(ws);
const admitted = pending.admission;
let admitted = pending.admission;
if (!admitted) throw new Error("Admitted hello has no session admission");
if (message.sessionPermissions)
admitted = {
...admitted,
permissions: admitted.permissions.filter((permission) =>
message.sessionPermissions!.includes(permission),
),
};
pending.admission = admitted;
pending.identity.clientId = clientId;
if (message.appVersion) {
pending.identity.appVersion = message.appVersion;
@@ -1640,6 +1719,9 @@ export class VoiceAssistantWebSocketServer {
lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" },
admission: admitted,
});
connection.permissionCeiling = message.sessionPermissions;
connection.session.setRouted(message.routed === true);
this.assignHostSource(connection, message, admitted.principalId, pluginId);
this.sessions.set(ws, connection);
if (connection.lifecycle === "reconnectable") {
this.externalSessionsByKey.set(sessionKey, connection);
@@ -1755,6 +1837,14 @@ export class VoiceAssistantWebSocketServer {
clearTimeout(existing.externalDisconnectCleanupTimeout);
existing.externalDisconnectCleanupTimeout = null;
}
for (const socket of existing.sockets) {
this.hostLinks.get(socket)?.close("permission_denied");
this.hostLinks.delete(socket);
}
existing.permissionCeiling = message.sessionPermissions;
existing.session.setPermissions(pending.admission!.permissions);
existing.session.setRouted(message.routed === true);
this.assignHostSource(existing, message, pending.admission!.principalId);
const newAppVersion = message.appVersion ?? null;
if (newAppVersion && newAppVersion !== existing.appVersion) {
existing.appVersion = newAppVersion;
@@ -1785,6 +1875,21 @@ export class VoiceAssistantWebSocketServer {
);
}
private assignHostSource(
connection: SessionConnection,
hello: WSHelloMessage,
principalId: string,
pluginId?: string,
): void {
if (pluginId) return;
const producer = hello.producer;
const sourceId =
hello.routed && producer
? `route:${[principalId, producer.serverId, producer.sourceId].map(encodeURIComponent).join(":")}`
: principalId;
connection.session.setHostSource(sourceId);
}
private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload {
return {
status: "server_info",
@@ -1792,11 +1897,14 @@ 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,
hostMultiplexing: true,
usageSources: true,
ownedSubscriptions: true,
agentRequestReceipts: true,
@@ -2038,6 +2146,9 @@ export class VoiceAssistantWebSocketServer {
error?: Error;
},
): Promise<void> {
this.hostLinks.get(ws)?.close();
this.hostLinks.delete(ws);
this.applicationSocketLease.release(ws);
const identity = this.socketIdentities.get(ws);
const identityFields = identity ? toConnectionLogFields(identity) : {};
@@ -2086,6 +2197,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);
@@ -2228,6 +2343,12 @@ export class VoiceAssistantWebSocketServer {
return false;
}
const decodedFrame = decodeBinaryFrame(asBytes);
if (asBytes[0] === HOST_ROUTE_OPCODE && !decodedFrame) {
ws.close(1008, "Invalid host routing frame");
return true;
}
if (decodedFrame?.kind === "host_route" && this.hostLinks.get(ws)?.binary(decodedFrame.frame))
return true;
if (!decodedFrame) {
return false;
}
@@ -2382,11 +2503,38 @@ export class VoiceAssistantWebSocketServer {
}
}
private hostLink(ws: WebSocketLike, connection: SessionConnection): DaemonLinkEndpoint {
let link = this.hostLinks.get(ws);
if (!link) {
link = new DaemonLinkEndpoint({
identity: JSON.stringify([connection.principalId, connection.clientId]),
permissions: () => connection.session.getPermissions(),
routed: () => connection.session.isRouted(),
bufferedAmount: () => ws.bufferedAmount ?? null,
delivery: connection.session.delivery,
admit: (socket, permissions) =>
this.attachSocket(socket, undefined, undefined, false, {
principalId: connection.principalId,
permissions,
}),
});
this.hostLinks.set(ws, link);
}
return link;
}
private async dispatchSessionMessage(
ws: WebSocketLike,
activeConnection: SessionConnection,
message: Extract<WSInboundMessage, { type: "session" }>,
): Promise<void> {
const link = this.hostLink(ws, activeConnection);
if (link.handles(message.message)) {
await activeConnection.session.delivery.request(ws, message.message, () =>
link.dispatch(message.message),
);
return;
}
this.recordInboundSessionRequestType(message.message.type);
const controlRpc = getControlRpcLogInfo(message.message);
if (controlRpc) {
+25
View File
@@ -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:
+20
View File
@@ -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:
+60
View File
@@ -59,6 +59,8 @@ Create a new client after `close()`.
Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `outputSchema`, `images`, `attachments`, `git`, `worktree`, `autoArchive`, `background`, and `labels`.
`parent` accepts a host-local agent ID, an `AgentAddress` (`{ serverId, agentId }`), or an agent handle. A handle’s `address` keeps its host identity after disconnect. When creating through `hosts.client(serverId)`, pass the parent handle or complete address; the SDK carries parent metadata to the destination.
`background` is workspace creation intent. It cannot accompany an existing `workspaceId`.
See [background workspaces](./workspaces.md) for caller inheritance and inclusive discovery.
@@ -80,6 +82,7 @@ See [background workspaces](./workspaces.md) for caller inheritance and inclusiv
| Member | Result | Behavior |
| ------------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `id` | `string` | Stable daemon agent ID. |
| `address` | `AgentAddress` | Complete `{ serverId, agentId }` identity; requires a connected host to establish it. |
| `workspaceId` | `string \| null` | Current workspace placement. |
| `cwd` | `string \| null` | Current working directory. |
| `status` | Agent status or `null` | Current lifecycle status. |
@@ -203,6 +206,63 @@ 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. Observations recover through the ordinary client lifecycle. While connecting,
new requests use the existing bounded send queue; disconnected requests fail. Connection loss clears
old waiters and queued sends. Ordinary non-recoverable requests are not replayed; existing
acknowledged/idempotent creation recovery retains its request identity. A submitted request whose response
is lost rejects with `DaemonConnectionError` (`code: "DAEMON_CONNECTION_LOST"`), optional
`outcome: "unknown"` and known `serverId`. Inspect before retrying a mutation.
Registration and host acquisition failures expose a stable `code`: `host_unknown`, `host_disabled`, `host_offline`,
`permission_denied`, `unsupported_capability`, `authentication_failed`, `server_id_mismatch`,
`self_host`, or `duplicate_host`. Ordinary RPC permission codes, including `access_denied`, pass through
unchanged. Multiplexing is a transport; consumers use the same client/API operations and lifecycle.
Discovery does not grant execution authority.
## Errors and cleanup
Connection, validation, rejection, and timeout failures reject their promise. Turn outcomes are returned through `PaseoAgentRunResult.status` because permission and provider errors are expected agent states.
+1 -1
View File
@@ -475,7 +475,7 @@ test("Windows units and integration run independently with complete routed cover
assert.doesNotMatch(invocations[0], / -t /);
assert.equal(
invocations[1],
'vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t "merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments"',
'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/);