mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 01:32:40 +00:00
fix(hosts): validate safe targets and verify encrypted relay routes
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user