fix(hosts): validate safe targets and verify encrypted relay routes

This commit is contained in:
Mohamed Boudra
2026-10-21 10:33:11 +02:00
parent 3b50636e14
commit 32fbcdc4a8
15 changed files with 412 additions and 57 deletions
+1
View File
@@ -25,6 +25,7 @@ permissions closes existing routes. A routed API cannot select a second hop.
A producer that registers a host through a routed API is qualified on B as
`route:<B principal>:<A serverId>:<A producer>`, with each component percent-encoded. This namespace belongs to the authenticated link
principal. An ordinary direct B consumer does not become that producer by supplying a source key.
The optional `server_info.sourceId` reports this producer identity for source-owner controls; it does not describe the authenticated admission principal.
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
## Connections and recovery
@@ -28,6 +28,9 @@ export async function correctConnection(page: Page, value: string) {
await saveConnectedHost(page);
}
export async function inspectConnectedHost(page: Page, label: string) {
await expect(
page.getByText(/^(connecting|offline|error|online|disabled) · (Enabled|Disabled) · User$/),
).toBeVisible();
await page.getByRole("button", { name: `Details for ${label}`, exact: true }).click();
await expect(page.getByText("Connected host details", { exact: true })).toBeVisible();
}
+23 -3
View File
@@ -85,7 +85,7 @@ function ConnectedHosts({ client, connected }: { client: DaemonClient; connected
model={model}
canManage={canManage}
manageSources={info?.permissions?.includes("access.manage") === true}
sourceId={info?.principalId}
sourceId={info?.sourceId}
pending={state.pending !== null}
/>
) : null}
@@ -151,7 +151,7 @@ function HostRow({
return (
<SettingsRow
label={host.label ?? host.serverId ?? host.registrationId}
hint={`${host.status} · ${host.enabled ? "Enabled" : "Disabled"} · ${host.source.id}/${host.source.key}`}
hint={`${host.status} · ${host.enabled ? "Enabled" : "Disabled"} · ${sourceLabel(host.source.id)}`}
>
<View style={styles.actions}>
<Button
@@ -180,7 +180,9 @@ function HostDetails({ host }: { host: HostSummary }) {
<SettingsRow label="Connection" hint={host.targetDescription} />
<SettingsRow label="Metadata" hint={JSON.stringify(host.metadata)} />
<SettingsRow label="Status" hint={host.status} />
<SettingsRow label="Source" hint={`${host.source.id}/${host.source.key}`} />
<SettingsRow label="Source" hint={sourceLabel(host.source.id)} />
<SettingsRow label="Source identity" hint={host.source.id} />
<SettingsRow label="Source key" hint={host.source.key} />
<SettingsRow label="Last online" hint={host.lastOnlineAt ?? "Never"} />
<SettingsRow label="Next retry" hint={host.nextRetryAt ?? "None scheduled"} />
{host.error ? <SettingsRow label={host.error.code} error={host.error.message} /> : null}
@@ -293,3 +295,21 @@ const styles = StyleSheet.create((theme) => ({
section: { gap: theme.spacing[3] },
actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] },
}));
function sourceLabel(sourceId: string): string {
if (sourceId === "owner") return "User";
if (sourceId.startsWith("plugin:")) return `Plugin: ${sourceId.slice("plugin:".length)}`;
if (sourceId.startsWith("route:")) {
const components = sourceId.split(":");
let producer: string;
try {
producer = decodeURIComponent(components[3] ?? "");
} catch {
return "User (via host)";
}
return producer.startsWith("plugin:")
? `Plugin: ${producer.slice("plugin:".length)} (via host)`
: "User (via host)";
}
return "User";
}
+3 -3
View File
@@ -42,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),
};
}
+17 -1
View File
@@ -1,6 +1,10 @@
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 +48,18 @@ test("endpoint descriptions redact pairing material and credentials", () => {
).not.toContain("private");
});
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;
+3 -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,8 @@ 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();
return parseHostTarget(target.host).description;
} catch {
return target.host.replace(/([?&](?:password|token|secret)=)[^&]*/gi, "$1REDACTED");
return "<invalid endpoint>";
}
}
+41 -1
View File
@@ -20,7 +20,7 @@ test("SSH daemon credentials are separate from SSH authentication and excluded f
kind: "ssh",
ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 },
password: "secret",
description: "ssh://deploy@build-box:2222 (daemon port 7777)",
description: "ssh://build-box:2222 (daemon port 7777)",
});
});
test.each([
@@ -31,6 +31,46 @@ test.each([
"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");
}
});
+19 -5
View File
@@ -64,7 +64,7 @@ function parse(input: string): HostTarget {
}
if (input.startsWith("tcp://")) return tcpTarget(input);
if (/^wss?:\/\//.test(input)) return websocketTarget(input);
const endpoint = normalizeHostPort(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
return {
kind: "socket",
url: buildDaemonWebSocketUrl(endpoint, { useTls: false }),
@@ -72,23 +72,27 @@ function parse(input: string): HostTarget {
};
}
function relayTarget(offer: ConnectionOffer, password?: string): HostTarget {
const endpoint = validatedEndpoint(offer.relay.endpoint);
return {
kind: "socket",
url: buildRelayWebSocketUrl({
endpoint: offer.relay.endpoint,
endpoint,
serverId: offer.serverId,
role: "client",
useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
}),
offer,
password,
description: `relay://${offer.relay.endpoint}/${offer.serverId}`,
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 = normalizeHostPort(
const endpoint = validatedEndpoint(
`${parts.isIpv6 ? `[${parts.host}]` : parts.host}:${parts.port}`,
);
return {
@@ -118,6 +122,16 @@ function sshTarget(input: string): HostTarget {
kind: "ssh",
ssh,
...(password ? { password } : {}),
description: `ssh://${ssh.host}${ssh.sshPort ? `:${ssh.sshPort}` : ""} (daemon port ${ssh.daemonPort})`,
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);
}
+34 -17
View File
@@ -902,8 +902,10 @@ export type RecentProviderSessionDescriptorPayload = z.infer<
typeof RecentProviderSessionDescriptorPayloadSchema
>;
// =====================================================================// Session Inbound Messages (Session receives these)
// =====================================================================
// ============================================================================
// Session Inbound Messages (Session receives these)
// ============================================================================
export const VoiceAudioChunkMessageSchema = z.object({
type: z.literal("voice_audio_chunk"),
audio: z.string(), // base64 encoded
@@ -1229,8 +1231,10 @@ export const SendAgentMessageSchema = z.object({
attachments: AgentAttachmentsSchema,
});
// =====================================================================// Agent RPCs (requestId-correlated)
// =====================================================================
// ============================================================================
// Agent RPCs (requestId-correlated)
// ============================================================================
const DirectorySyncRequestSchema = z.object({
generation: z.string().optional(),
afterSeq: z.number().int().nonnegative().optional(),
@@ -1654,8 +1658,10 @@ export const WriteProjectConfigRequestMessageSchema = z.object({
expectedRevision: PaseoConfigRevisionSchema.nullable(),
});
// =====================================================================// Dictation Streaming (lossless, resumable)
// =====================================================================
// ============================================================================
// Dictation Streaming (lossless, resumable)
// ============================================================================
export const DictationStreamStartMessageSchema = z.object({
type: z.literal("dictation_stream_start"),
dictationId: z.string(),
@@ -2952,8 +2958,10 @@ export const PushUnregisterResponseSchema = z.object({
}),
});
// =====================================================================// Terminal Messages
// =====================================================================
// ============================================================================
// Terminal Messages
// ============================================================================
export const ListTerminalsRequestSchema = z.object({
type: z.literal("list_terminals_request"),
cwd: z.string().optional(),
@@ -3436,8 +3444,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
// =====================================================================// Session Outbound Messages (Session emits these)
// =====================================================================
// ============================================================================
// Session Outbound Messages (Session emits these)
// ============================================================================
export const ActivityLogPayloadSchema = z.object({
subscriptionId: z.string().optional(),
id: z.string(),
@@ -3592,8 +3602,9 @@ 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.
principalId: z.string().optional(),
permissions: z.array(DaemonPermissionSchema).optional(),
// COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16.
desktopManaged: z.boolean().optional(),
@@ -6414,8 +6425,10 @@ export const ListCommandsResponseSchema = z.object({
}),
});
// =====================================================================// Terminal Outbound Messages
// =====================================================================
// ============================================================================
// Terminal Outbound Messages
// ============================================================================
const TerminalInfoSchema = z.object({
id: z.string(),
name: z.string(),
@@ -7580,8 +7593,10 @@ export type CaptureTerminalRequest = z.infer<typeof CaptureTerminalRequestSchema
export type CaptureTerminalResponse = z.infer<typeof CaptureTerminalResponseSchema>;
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
// =====================================================================// WebSocket Level Messages (wraps session messages)
// =====================================================================
// ============================================================================
// WebSocket Level Messages (wraps session messages)
// ============================================================================
// WebSocket-only messages (not session messages)
export const WSPingMessageSchema = z.object({
type: z.literal("ping"),
@@ -7671,8 +7686,10 @@ export type WSInboundMessage = z.infer<typeof WSInboundMessageSchema>;
export type WSOutboundMessage = z.infer<typeof WSOutboundMessageSchema>;
export type WSHelloMessage = z.infer<typeof WSHelloMessageSchema>;
// =====================================================================// Helper functions for message conversion
// =====================================================================
// ============================================================================
// Helper functions for message conversion
// ============================================================================
/**
* Extract session message from WebSocket message
* Returns null if message should be handled at WS level only
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments\"",
"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",
@@ -19,6 +19,8 @@ async function api(instance: TestPaseoDaemon) {
reconnect: { enabled: false },
});
await client.connect();
expect(client.getLastServerInfoMessage()).toMatchObject({ sourceId: "owner" });
expect(client.getLastServerInfoMessage()).not.toHaveProperty("principalId");
const paseo = createPaseoApi(client);
cleanup.push(async () => {
await paseo.dispose();
@@ -46,6 +48,16 @@ test("SDK saves unreachable hosts, rejects syntax and preserves source identity
await expect(paseo.hosts.add({ connection: "ssh://-bad" })).rejects.toMatchObject({
code: "invalid_connection",
});
for (const connection of [
"alice:example-secret@localhost:6767",
"not-a-scheme://localhost:6767",
"localhost/path:6767",
]) {
const failure = await paseo.hosts.add({ connection }).catch((error: unknown) => error);
expect(failure).toMatchObject({ code: "invalid_connection" });
expect(String(failure)).not.toContain("example-secret");
expect((await paseo.hosts.list()).hosts).toEqual([]);
}
const saved = await paseo.hosts.add({
connection: "tcp://127.0.0.1:1?password=private-secret",
label: "Unavailable",
@@ -241,6 +253,14 @@ class RoutePeer {
).length,
)
.toBe(1);
const info = peer.messages.find(
(message) => message.type === "status" && message.payload.status === "server_info",
);
if (info?.type !== "status") throw new Error("Missing routed server info");
expect(info.payload).toMatchObject({
sourceId: `route:owner:${origin.getLastServerInfoMessage()!.serverId}:owner`,
});
expect(info.payload).not.toHaveProperty("principalId");
return peer;
}
send(message: SessionInboundMessage) {
@@ -436,6 +456,13 @@ test("CLI host commands manage the selected daemon and emit secret-free structur
)
).stdout,
);
const malformed = await cli("add", "alice:example-secret@localhost:6767").catch(
(error: unknown) => error,
);
expect(malformed).toHaveProperty("stderr");
expect((malformed as { stderr: string }).stderr).toContain("invalid_connection");
expect((malformed as { stderr: string }).stderr).not.toContain("example-secret");
expect(await cli("ls")).toEqual([]);
const saved = await cli(
"add",
"tcp://127.0.0.1:1?password=cli-secret",
@@ -1,3 +1,14 @@
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { createHostClient } from "@getpaseo/client/node";
import { hashSync } from "bcryptjs";
import { once } from "node:events";
import {
decodeTerminalStreamFrame,
encodeTerminalStreamFrame,
TerminalStreamOpcode,
} from "@getpaseo/protocol/binary-frames/index";
import type { DaemonClient as HostClient } from "@getpaseo/client/internal/daemon-client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { describe, expect, test } from "vitest";
import { withTimeout } from "../../utils/promise-timeout.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
@@ -194,9 +205,9 @@ async function waitForCapturedLog(
let relayProcess: ChildProcess | null = null;
let relayStdoutLines: string[] = [];
const startRelay = async (options: { useLocalRelay?: boolean } = {}) => {
const startRelay = async (options: { useLocalRelay?: boolean; port?: number } = {}) => {
relayStdoutLines = [];
relayPort = await getAvailablePort();
relayPort = options.port ?? (await getAvailablePort());
const relayDir = path.resolve(process.cwd(), "../relay");
const relayArgs = [
"wrangler",
@@ -255,10 +266,142 @@ async function waitForCapturedLog(
const stopRelay = async () => {
if (!relayProcess) return;
relayProcess.kill("SIGTERM");
const process = relayProcess;
relayProcess = null;
const exited = once(process, "exit");
process.kill("SIGTERM");
await withTimeout(exited, 10000, "Local relay did not stop");
};
test("connected hosts route RPC and binary traffic through local E2EE relay and recover scopes", async () => {
let a: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let b: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let origin: ReturnType<typeof createHostClient> | undefined;
let api: PaseoApi | undefined;
let remote: PaseoApi | undefined;
let sibling: PaseoApi | undefined;
let terminalRoute: Awaited<ReturnType<typeof openConnectedHostTerminal>> | undefined;
try {
await startRelay({ useLocalRelay: true });
const port = relayPort;
b = await createTestPaseoDaemon({
relayEnabled: true,
relayEndpoint: `127.0.0.1:${port}`,
relayUseTls: false,
relayPublicUseTls: false,
auth: { password: hashSync("relay-password", 4) },
});
a = await createTestPaseoDaemon();
origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() });
await origin.connect();
api = createPaseoApi(origin);
const offerUrl = await getPairingOfferUrl({
paseoHome: b.paseoHome,
relayEnabled: true,
relayEndpoint: b.config.relayEndpoint,
});
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
const connection = new URL(`relay://127.0.0.1:${port}/${serverId}`);
connection.searchParams.set("key", daemonPublicKeyB64);
connection.searchParams.set("password", "wrong-password");
const saved = await api.hosts.add({ connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 })
.toBe("authentication_failed");
expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/);
connection.searchParams.set("password", "relay-password");
await api.hosts.update(saved.registrationId, { connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 })
.toBe("online");
remote = await api.hosts.client(serverId);
sibling = await api.hosts.client(serverId);
expect((await remote.workspaces.list()).entries).toEqual([]);
const observed = await remote.workspaces.list({ subscribe: {} });
let snapshots = 0;
observed.subscription.subscribe({
snapshot: () => {
snapshots++;
},
});
const workspace = await remote.workspaces.create({
source: { kind: "directory", path: b.staticDir },
title: "Relayed workspace",
});
const terminal = await workspace.terminals.create({
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
});
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE");
terminalRoute.close();
terminalRoute = undefined;
const before = snapshots;
await stopRelay();
await expect
.poll(
async () => {
try {
await remote!.workspaces.list();
return "online";
} catch (error) {
return (error as { code: string }).code;
}
},
{ timeout: 15000 },
)
.toBe("host_offline");
await startRelay({ useLocalRelay: true, port });
await expect
.poll(
async () => {
try {
return (await remote!.workspaces.list()).entries.length;
} catch {
return -1;
}
},
{ timeout: 30000 },
)
.toBe(1);
await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before);
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
terminalRoute.write("ENCRYPTED_BINARY_AFTER\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER");
terminalRoute.close();
terminalRoute = undefined;
await remote.dispose();
remote = undefined;
expect((await sibling.workspaces.list()).entries).toHaveLength(1);
await sibling.terminals.ref(terminal.id).kill();
await api.dispose();
await expect(sibling.workspaces.list()).rejects.toThrow();
const direct = createHostClient(`127.0.0.1:${b.port}`, {
clientId: crypto.randomUUID(),
password: "relay-password",
reconnect: { enabled: false },
});
try {
await direct.connect();
const directApi = createPaseoApi(direct);
expect((await directApi.workspaces.list()).entries).toHaveLength(1);
await directApi.dispose();
} finally {
await direct.close();
}
} finally {
terminalRoute?.close();
await remote?.dispose();
await sibling?.dispose();
await api?.dispose();
await origin?.close();
await a?.close();
await b?.close();
await stopRelay();
}
}, 120000);
test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => {
await startRelay({ useLocalRelay: true });
const daemon = await createTestPaseoDaemon({
@@ -932,3 +1075,73 @@ async function waitForCapturedLog(
}
}, 90000);
});
async function openConnectedHostTerminal(origin: HostClient, serverId: string, terminalId: string) {
const routeId = await origin.openHostRoute(serverId, crypto.randomUUID());
const messages: SessionOutboundMessage[] = [];
let output = "";
const detach = origin.subscribeRawMessages((message) => {
if (message.type !== "hosts.route.frame" || message.payload.subscriptionId !== routeId) return;
if (message.payload.binary) {
const frame = decodeTerminalStreamFrame(Buffer.from(message.payload.data, "base64"));
if (frame?.opcode === TerminalStreamOpcode.Output)
output += new TextDecoder().decode(frame.payload);
} else {
const frame = WSOutboundMessageSchema.parse(JSON.parse(message.payload.data));
if (frame.type === "session") messages.push(frame.message);
}
});
const close = () => {
detach();
origin.closeHostRoute(routeId);
};
try {
origin.sendHostFrame(
routeId,
JSON.stringify({
type: "hello",
clientId: "relay-binary",
clientType: "cli",
protocolVersion: 1,
}),
false,
);
await expect
.poll(() =>
messages.some(
(message) => message.type === "status" && message.payload.status === "server_info",
),
)
.toBe(true);
origin.sendHostFrame(
routeId,
JSON.stringify({
type: "session",
message: { type: "subscribe_terminal_request", terminalId, requestId: "binary" },
}),
false,
);
await expect
.poll(() => messages.some((message) => message.type === "subscribe_terminal_response"))
.toBe(true);
const response = messages.find((message) => message.type === "subscribe_terminal_response");
if (response?.type !== "subscribe_terminal_response" || response.payload.slot == null)
throw new Error("Missing binary stream slot");
const slot = response.payload.slot;
return {
close,
output: () => output,
write: (payload: string) =>
origin.sendHostFrame(
routeId,
Buffer.from(
encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Input, slot, payload }),
).toString("base64"),
true,
),
};
} catch (error) {
close();
throw error;
}
}
+5 -5
View File
@@ -677,12 +677,12 @@ export class Session {
isRouted(): boolean {
return this.routed;
}
getPrincipalId(): string {
return this.hostPrincipalId;
getHostSourceId(): string {
return this.hostSourceId;
}
private hostPrincipalId: string;
private hostSourceId: string;
setHostSource(sourceId: string): void {
this.hostPrincipalId = sourceId;
this.hostSourceId = sourceId;
this.connectedHostSession?.setSource(sourceId);
}
setRouted(routed: boolean): void {
@@ -882,7 +882,7 @@ export class Session {
this.appVersion = appVersion ?? null;
this.clientCapabilities = parseClientCapabilities(clientCapabilities);
this.sessionId = uuidv4();
this.hostPrincipalId = hostSource(options);
this.hostSourceId = hostSource(options);
this.connectedHostSession = createHostSession(options, this);
this.onMessage = onMessage;
this.onMessageToSource = onMessageToSource ?? null;
@@ -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", () => {
@@ -1846,7 +1846,7 @@ export class VoiceAssistantWebSocketServer {
serverId: this.serverId,
hostname: getHostName(),
version: this.daemonVersion,
principalId: session.getPrincipalId(),
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,