feat: direct TCP URI with SSL toggle and optional password auth (#635)

* feat: direct TCP URI with SSL toggle and optional password auth

Replaces the heuristic-driven direct-connection model with a user-controlled
one. The Add Host dialog now exposes structured Host, Port, "Use SSL", and
masked Password fields that compose the canonical
`tcp://host:port?ssl=true&password=xxx` URI used as the storage form.

The daemon gains optional shared-secret auth: `Authorization: Bearer <pw>` on
HTTP and `Sec-WebSocket-Protocol: paseo.bearer.<pw>` on the WS upgrade
(browser WebSocket can't set custom headers). Configured via config.json
`auth.password` or `PASEO_PASSWORD` env. Off by default — old clients keep
working unchanged.

The `port === 443` heuristic for ws/wss is gone; the explicit `useTls` flag
drives scheme selection at every call site.

* fix: stabilize direct tcp auth ci checks

* fix: restore fetch stub in bootstrap smoke test

* fix(app): collapse Advanced section in add-host modal

* feat(server): hash daemon password in config

* fix: update lockfile signatures and Nix hash

* Improve direct TCP auth failures

* Fix workspace cwd updates after rebase

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Mohamed Boudra
2026-04-30 22:05:49 +08:00
committed by GitHub
co-authored by github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent 30f7842e8f
commit 088a6c14c0
48 changed files with 1829 additions and 129 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-GVJ9z48+9tohv1AbD517XNKoLFO+qFkjNUO5KBFfgyM=";
npmDepsHash = "sha256-Wc/BRKf6bRq30FazffKkXcXTF9bo5WFDm6OIxwiNLb4=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+10
View File
@@ -16691,6 +16691,15 @@
"node": ">=10.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-opn": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz",
@@ -38668,6 +38677,7 @@
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.17.1",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
@@ -59,6 +59,35 @@ test.describe("Settings sidebar navigation", () => {
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
});
test("direct connection advanced URI round-trips SSL and password into the form", async ({
page,
}) => {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-add-host").click();
await page.getByRole("button", { name: "Direct connection" }).click();
await page.getByTestId("direct-host-advanced-toggle").click();
await page
.getByTestId("direct-host-uri-input")
.fill("tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-input")).toHaveValue("example.paseo.test");
await expect(page.getByTestId("direct-port-input")).toHaveValue("7443");
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
await expect(page.getByTestId("direct-password-input")).toHaveValue("shared-secret");
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
);
await page.getByTestId("direct-host-advanced-toggle").click();
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
});
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
+336 -55
View File
@@ -1,17 +1,35 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { useCallback, useMemo, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link2 } from "lucide-react-native";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { normalizeHostPort } from "@/utils/daemon-endpoints";
import {
parseConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
interface DirectConnectionDraft {
host: string;
port: string;
useTls: boolean;
password: string;
}
interface PreparedDirectConnection {
uri: string;
endpoint: string;
useTls: boolean;
password?: string;
}
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
@@ -30,6 +48,66 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
},
portRow: {
flexDirection: "row",
gap: theme.spacing[3],
},
hostField: {
flex: 1,
minWidth: 0,
},
portField: {
width: 112,
},
passwordRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
passwordInput: {
flex: 1,
minWidth: 0,
},
iconButton: {
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
},
checkboxRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
checkbox: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
advancedToggle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1],
},
advancedText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
@@ -45,8 +123,51 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function isHostPortOnly(raw: string): boolean {
return !raw.includes("://") && !raw.includes("/");
function isIpv6Host(host: string): boolean {
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
}
function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
const host = draft.host.trim();
const port = Number(draft.port.trim());
if (!host) {
throw new Error("Host is required");
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Port must be between 1 and 65535");
}
return serializeConnectionUriForStorage({
host,
port,
isIpv6: isIpv6Host(host),
useTls: draft.useTls,
...(draft.password ? { password: draft.password } : {}),
});
}
function prepareDirectConnection(draft: DirectConnectionDraft): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft));
const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`;
return {
uri: serializeConnectionUri(parsed),
endpoint,
useTls: parsed.useTls,
...(parsed.password ? { password: parsed.password } : {}),
};
}
function draftFromConnectionUri(uri: string): DirectConnectionDraft {
const parsed = parseConnectionUri(uri);
return {
host: parsed.host,
port: String(parsed.port),
useTls: parsed.useTls,
password: parsed.password ?? "",
};
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
@@ -56,7 +177,7 @@ function normalizeTransportMessage(message: string | null | undefined): string |
return trimmed;
}
function formatTechnicalTransportDetails(details: Array<string | null>): string | null {
function formatTechnicalTransportDetails(details: (string | null)[]): string | null {
const unique = Array.from(
new Set(
details
@@ -103,7 +224,9 @@ function buildConnectionFailureCopy(
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (rawLower.includes("timed out")) {
if (raw === "Incorrect password" || raw === "Password required") {
detail = raw;
} else if (rawLower.includes("timed out")) {
detail = "Connection timed out. Check the host/port and your network.";
} else if (
rawLower.includes("econnrefused") ||
@@ -121,9 +244,7 @@ function buildConnectionFailureCopy(
rawLower.includes("ssl")
) {
detail =
"TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
"TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon.";
} else {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
}
@@ -149,21 +270,41 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { upsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const hostInputRef = useRef<TextInput>(null);
const endpointRawRef = useRef("");
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [host, setHost] = useState("");
const [port, setPort] = useState("6767");
const [useTls, setUseTls] = useState(false);
const [password, setPassword] = useState("");
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState("");
const clearInput = useCallback(() => {
endpointRawRef.current = "";
hostInputRef.current?.clear();
setHost("");
setPort("6767");
setUseTls(false);
setPassword("");
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
}, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.palette.white} />,
[theme.colors.palette.white],
);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
const checkboxStyle = useMemo(
() => [styles.checkbox, useTls ? styles.checkboxChecked : null],
[useTls],
);
const passwordInputStyle = useMemo(() => [styles.input, styles.passwordInput], []);
const useTlsAccessibilityState = useMemo(
() => ({ checked: useTls, disabled: isSaving }),
[isSaving, useTls],
);
const handleClose = useCallback(() => {
if (isSaving) return;
@@ -182,21 +323,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = endpointRawRef.current.trim();
if (!raw) {
setErrorMessage("Host is required");
return;
}
if (!isHostPortOnly(raw)) {
setErrorMessage("Enter host:port only (no ws://, no /ws)");
return;
}
let endpoint: string;
let connection: PreparedDirectConnection;
try {
endpoint = normalizeHostPort(raw);
connection = prepareDirectConnection({ host, port, useTls, password });
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid host:port";
const message = error instanceof Error ? error.message : "Invalid connection";
setErrorMessage(message);
return;
}
@@ -208,20 +339,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { client, serverId, hostname } = await connectToDaemon({
id: "probe",
type: "directTcp",
endpoint,
endpoint: connection.endpoint,
useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}),
});
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
const profile = await upsertDirectConnection({
serverId,
endpoint,
endpoint: connection.endpoint,
useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}),
label: hostname ?? undefined,
});
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(endpoint, error);
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(connection.uri, error);
let combined: string;
if (rawDetail && detail && rawDetail !== detail) {
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
@@ -232,17 +367,23 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
setErrorMessage(combined);
if (!isMobile) {
// Desktop/web: also surface it as a dialog for quick visibility.
Alert.alert("Connection failed", combined);
}
} finally {
setIsSaving(false);
}
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
const handleChangeEndpoint = useCallback((next: string) => {
endpointRawRef.current = next;
}, []);
}, [
daemons,
handleClose,
host,
isMobile,
isSaving,
onSaved,
password,
port,
upsertDirectConnection,
useTls,
]);
const handleSubmitEditing = useCallback(() => {
void handleSave();
@@ -252,6 +393,43 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
void handleSave();
}, [handleSave]);
const handleToggleUseTls = useCallback(() => {
if (isSaving) return;
setUseTls((current) => !current);
}, [isSaving]);
const handleTogglePasswordVisibility = useCallback(() => {
setIsPasswordVisible((current) => !current);
}, []);
const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) {
try {
setAdvancedUri(buildConnectionUriFromDraft({ host, port, useTls, password }));
} catch {
setAdvancedUri("");
}
setErrorMessage("");
setIsAdvancedOpen(true);
return;
}
try {
const next = draftFromConnectionUri(advancedUri);
setHost(next.host);
setPort(next.port);
setUseTls(next.useTls);
setPassword(next.password);
setErrorMessage("");
} catch {
setErrorMessage("");
}
setIsAdvancedOpen(false);
}, [advancedUri, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
return (
<AdaptiveModalSheet
title="Direct connection"
@@ -261,24 +439,127 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.portRow}>
<View style={hostFieldStyle}>
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="Host"
value={host}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="next"
/>
</View>
<View style={portFieldStyle}>
<Text style={styles.label}>Port</Text>
<AdaptiveTextInput
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel="Port"
value={port}
onChangeText={setPort}
placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="number-pad"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
</View>
</View>
<Pressable
style={styles.checkboxRow}
onPress={handleToggleUseTls}
disabled={isSaving}
accessibilityRole="checkbox"
accessibilityLabel="Use SSL"
accessibilityState={useTlsAccessibilityState}
testID="direct-ssl-toggle"
>
<View style={checkboxStyle}>
{useTls ? (
<View testID="direct-ssl-toggle-checked">
<Check size={14} color={theme.colors.palette.white} />
</View>
) : null}
</View>
<Text style={styles.label}>Use SSL</Text>
</Pressable>
<View style={styles.field}>
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
ref={hostInputRef}
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="direct-host-input"
onChangeText={handleChangeEndpoint}
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<Text style={styles.label}>Password</Text>
<View style={styles.passwordRow}>
<AdaptiveTextInput
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel="Password"
value={password}
onChangeText={setPassword}
placeholder="Optional"
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={!isPasswordVisible}
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<Pressable
style={styles.iconButton}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isPasswordVisible ? "Hide password" : "Show password"}
testID="direct-password-visibility-toggle"
>
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</View>
<View style={styles.field}>
<Pressable
style={styles.advancedToggle}
onPress={handleToggleAdvanced}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isAdvancedOpen ? "Hide advanced" : "Show advanced"}
testID="direct-host-advanced-toggle"
>
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedText}>Advanced</Text>
</Pressable>
{isAdvancedOpen ? (
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel="Connection URI"
value={advancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleToggleAdvanced}
/>
) : null}
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
+40 -4
View File
@@ -23,7 +23,7 @@ class FakeDaemonClient {
public closeCalls = 0;
public ensureConnectedCalls = 0;
public fetchAgentsCalls: FetchAgentsOptions[] = [];
public fetchAgentsResponses: Array<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> = [];
public fetchAgentsResponses: Awaited<ReturnType<DaemonClient["fetchAgents"]>>[] = [];
async connect(): Promise<void> {
this.connectCalls += 1;
@@ -283,9 +283,10 @@ function updateControllerSnapshot(
}
function makeProbeMap(
entries: Array<
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
>,
entries: [
string,
HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never,
][],
): HostRuntimeSnapshot["probeByConnectionId"] {
return new Map(entries);
}
@@ -1559,6 +1560,41 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("upsertDirectConnection stores SSL and password settings", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
await store.upsertDirectConnection({
serverId: "srv_tls_password",
endpoint: "example.paseo.test:7443",
useTls: true,
password: "shared-secret",
label: "tls host",
});
const host = store.getHosts().find((entry) => entry.serverId === "srv_tls_password");
expect(host?.connections).toEqual([
{
id: "direct:example.paseo.test:7443",
type: "directTcp",
endpoint: "example.paseo.test:7443",
useTls: true,
password: "shared-secret",
},
]);
store.syncHosts([]);
});
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
const store = new HostRuntimeStore({
deps: {
+20 -4
View File
@@ -16,12 +16,17 @@ import {
type HostConnection,
type HostProfile,
} from "@/types/host-connection";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
decodeOfferFragmentPayload,
normalizeHostPort,
shouldUseTlsForDefaultHostedRelay,
} from "@/utils/daemon-endpoints";
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { getOrCreateClientId } from "@/utils/client-id";
import {
selectBestConnection,
@@ -463,13 +468,17 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
if (connection.type === "directTcp") {
return new DaemonClient({
...base,
url: buildDaemonWebSocketUrl(connection.endpoint),
url: buildDaemonWebSocketUrl(connection.endpoint, {
useTls: connection.useTls ?? false,
}),
...(connection.password ? { password: connection.password } : {}),
});
}
return new DaemonClient({
...base,
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
serverId: host.serverId,
}),
e2ee: {
@@ -905,7 +914,7 @@ export class HostRuntimeController {
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
let hasChanged = this.host.serverId !== this.snapshot.serverId;
for (const key of Object.keys(patch) as Array<keyof HostRuntimeSnapshotPatch>) {
for (const key of Object.keys(patch) as (keyof HostRuntimeSnapshotPatch)[]) {
const incomingValue = patch[key];
if (equal(this.snapshot[key], incomingValue)) {
setSnapshotPatchField(preservedPatch, key, this.snapshot[key]);
@@ -1383,10 +1392,13 @@ export class HostRuntimeStore {
async upsertDirectConnection(input: {
serverId: string;
endpoint: string;
useTls?: boolean;
password?: string;
label?: string;
existingClient?: DaemonClient;
}): Promise<HostProfile> {
const endpoint = normalizeHostPort(input.endpoint);
const password = input.password?.trim();
return this.upsertHostConnection({
serverId: input.serverId,
label: input.label,
@@ -1394,6 +1406,8 @@ export class HostRuntimeStore {
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: input.useTls ?? false,
...(password ? { password } : {}),
},
existingClient: input.existingClient,
});
@@ -1964,6 +1978,8 @@ export interface HostMutations {
upsertDirectConnection: (input: {
serverId: string;
endpoint: string;
useTls?: boolean;
password?: string;
label?: string;
}) => Promise<HostProfile>;
upsertRelayConnection: (input: {
+5 -3
View File
@@ -242,14 +242,16 @@ interface DownloadTarget {
}
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
const endpoint = daemon?.connections.find((conn) => conn.type === "directTcp")?.endpoint ?? null;
if (!endpoint) {
const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null;
if (!connection) {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
let parsed: URL;
try {
parsed = new URL(buildDaemonWebSocketUrl(endpoint));
parsed = new URL(
buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
);
} catch {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { normalizeStoredHostProfile } from "./host-connection";
describe("normalizeStoredHostProfile", () => {
it("loads direct TCP connections stored before TLS and password fields existed", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_old",
label: "Old Host",
connections: [
{
id: "direct:127.0.0.1:6767",
type: "directTcp",
endpoint: "127.0.0.1:6767",
},
],
preferredConnectionId: "direct:127.0.0.1:6767",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
expect(profile).not.toBeNull();
expect(profile?.connections[0]).toEqual({
id: "direct:localhost:6767",
type: "directTcp",
endpoint: "localhost:6767",
useTls: false,
});
expect(profile?.connections[0]).not.toHaveProperty("password");
});
});
+17 -7
View File
@@ -1,10 +1,10 @@
import { normalizeHostPort, normalizeLoopbackToLocalhost } from "@server/shared/daemon-endpoints";
import {
DirectTcpHostConnectionSchema,
type DirectTcpHostConnection,
} from "@server/shared/host-connection-schema";
export interface DirectTcpHostConnection {
id: string;
type: "directTcp";
endpoint: string;
}
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
export interface DirectSocketHostConnection {
id: string;
@@ -58,7 +58,11 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
}
if (left.type === "directTcp" && right.type === "directTcp") {
return left.endpoint === right.endpoint;
return (
left.endpoint === right.endpoint &&
(left.useTls ?? false) === (right.useTls ?? false) &&
left.password === right.password
);
}
if (left.type === "directSocket" && right.type === "directSocket") {
return left.path === right.path;
@@ -238,7 +242,13 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
const endpoint = normalizeLoopbackToLocalhost(
normalizeHostPort(String(record.endpoint ?? "")),
);
return { id: `direct:${endpoint}`, type: "directTcp", endpoint };
return DirectTcpHostConnectionSchema.parse({
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: record.useTls,
...(typeof record.password === "string" ? { password: record.password } : {}),
});
} catch {
return null;
}
+13 -1
View File
@@ -4,7 +4,11 @@ import {
deriveLabelFromEndpoint,
extractHostPortFromWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
parseHostPort,
serializeConnectionUri,
serializeConnectionUriForStorage,
shouldUseTlsForDefaultHostedRelay,
type HostPortParts,
} from "@server/shared/daemon-endpoints";
@@ -17,9 +21,17 @@ export {
deriveLabelFromEndpoint,
extractHostPortFromWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
parseHostPort,
serializeConnectionUri,
serializeConnectionUriForStorage,
shouldUseTlsForDefaultHostedRelay,
};
export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string {
export function buildRelayWebSocketUrl(params: {
endpoint: string;
serverId: string;
useTls: boolean;
}): string {
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
}
@@ -1,10 +1,12 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const daemonClientMock = vi.hoisted(() => {
const createdConfigs: Array<{ clientId?: string; url?: string }> = [];
const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = [];
let nextConnectError: Error | null = null;
let nextLastError: string | null = null;
class MockDaemonClient {
public lastError: string | null = null;
public lastError: string | null = nextLastError;
private lastServerInfo = {
status: "server_info" as const,
serverId: "srv_probe_test",
@@ -12,7 +14,7 @@ const daemonClientMock = vi.hoisted(() => {
version: "0.0.0",
};
constructor(config: { clientId?: string; url?: string }) {
constructor(config: { clientId?: string; url?: string; password?: string }) {
createdConfigs.push(config);
}
@@ -25,6 +27,9 @@ const daemonClientMock = vi.hoisted(() => {
}
async connect(): Promise<void> {
if (nextConnectError) {
throw nextConnectError;
}
return;
}
@@ -44,6 +49,15 @@ const daemonClientMock = vi.hoisted(() => {
return {
MockDaemonClient,
createdConfigs,
setNextConnectFailure: (error: Error, lastError: string | null) => {
nextConnectError = error;
nextLastError = lastError;
},
reset: () => {
createdConfigs.length = 0;
nextConnectError = null;
nextLastError = null;
},
};
});
@@ -74,8 +88,9 @@ vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({
describe("test-daemon-connection connectToDaemon", () => {
beforeEach(() => {
daemonClientMock.createdConfigs.length = 0;
daemonClientMock.reset();
clientIdMock.getOrCreateClientId.mockClear();
vi.stubGlobal("__DEV__", false);
});
it("reuses the app clientId for direct connections", async () => {
@@ -115,4 +130,53 @@ describe("test-daemon-connection connectToDaemon", () => {
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
);
});
it("passes direct TCP connection passwords into the client config", async () => {
const mod = await import("./test-daemon-connection");
const result = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
});
await result.client.close();
expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
});
it("surfaces auth rejection as an incorrect password", async () => {
const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure(
new Error("Transport closed (code 4001)"),
"Transport closed (code 4001)",
);
await expect(
mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "wrong-secret",
}),
).rejects.toMatchObject({
message: "Incorrect password",
});
});
it("keeps generic transport failures generic when a password was supplied", async () => {
const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error");
await expect(
mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
}),
).rejects.toMatchObject({
message: "Transport error",
});
});
});
@@ -3,7 +3,11 @@ import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { getOrCreateClientId } from "./client-id";
import { resolveAppVersion } from "./app-version";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
shouldUseTlsForDefaultHostedRelay,
} from "./daemon-endpoints";
import {
buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory,
@@ -34,6 +38,23 @@ function pickBestReason(reason: string | null, lastError: string | null): string
return "Unable to connect";
}
function isIncorrectPasswordFailure(input: {
config: DaemonClientConfig;
reason: string | null;
lastError: string | null;
}): boolean {
if (!input.config.password) {
return false;
}
const details = [input.reason, input.lastError].filter(Boolean).join("\n").toLowerCase();
return (
details.includes("401") ||
details.includes("4001") ||
details.includes("unauthorized") ||
details.includes("code 1006")
);
}
export class DaemonConnectionTestError extends Error {
reason: string | null;
lastError: string | null;
@@ -78,7 +99,8 @@ export async function buildClientConfig(
if (connection.type === "directTcp") {
return {
...base,
url: buildDaemonWebSocketUrl(connection.endpoint),
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
...(connection.password ? { password: connection.password } : {}),
};
}
@@ -90,6 +112,7 @@ export async function buildClientConfig(
...base,
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
serverId,
}),
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
@@ -142,7 +165,9 @@ export function connectAndProbe(
error instanceof Error ? error.message : String(error),
);
const lastError = normalizeNonEmptyString(client.lastError);
const message = pickBestReason(reason, lastError);
const message = isIncorrectPasswordFailure({ config, reason, lastError })
? "Incorrect password"
: pickBestReason(reason, lastError);
void client.close().catch(() => undefined);
reject(new DaemonConnectionTestError(message, { reason, lastError }));
});
@@ -3,6 +3,7 @@ import { startCommand } from "./start.js";
import { runStatusCommand } from "./status.js";
import { runStopCommand } from "./stop.js";
import { runRestartCommand } from "./restart.js";
import { runSetPasswordCommand } from "./set-password.js";
import { pairCommand } from "./pair.js";
import { withOutput } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
@@ -60,5 +61,13 @@ export function createDaemonCommand(): Command {
}),
);
addJsonOption(
daemon
.command("set-password")
.description("Prompt for and save a hashed daemon password to config.json"),
)
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runSetPasswordCommand));
return daemon;
}
@@ -0,0 +1,126 @@
import path from "node:path";
import type { Command } from "commander";
import { isCancel, password as passwordPrompt } from "@clack/prompts";
import {
hashDaemonPassword,
loadPersistedConfig,
savePersistedConfig,
type PersistedConfig,
} from "@getpaseo/server";
import type {
CommandError,
CommandOptions,
OutputOptions,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import { resolveLocalPaseoHome } from "./local-daemon.js";
const CONFIG_FILENAME = "config.json";
interface SetPasswordResult {
action: "password_set";
configPath: string;
restartCommand: string;
message: string;
}
export type PromptPassword = (message: string) => Promise<string | symbol>;
export interface SetPasswordOptions {
home?: string;
promptPassword?: PromptPassword;
}
const setPasswordResultSchema: OutputSchema<SetPasswordResult> = {
idField: "action",
columns: [
{ header: "STATUS", field: "action", color: () => "green" },
{ header: "CONFIG", field: "configPath" },
{ header: "RESTART", field: "restartCommand" },
],
renderHuman: (result, options: OutputOptions) => {
const data = result.data as SetPasswordResult;
const rows = [
`Password written to ${data.configPath}`,
"Restart the daemon for the change to take effect.",
`Run: ${data.restartCommand}`,
];
if (options.format === "table") {
return rows.join("\n");
}
return data.message;
},
};
function createCommandError(code: string, message: string, details?: string): CommandError {
return { code, message, ...(details ? { details } : {}) };
}
async function promptForPassword(promptPassword: PromptPassword): Promise<string> {
const first = await promptPassword("New daemon password");
if (isCancel(first)) {
throw createCommandError("PASSWORD_CANCELLED", "Password update cancelled");
}
if (typeof first !== "string" || first.length === 0) {
throw createCommandError("PASSWORD_REQUIRED", "Password cannot be empty");
}
const second = await promptPassword("Confirm daemon password");
if (isCancel(second)) {
throw createCommandError("PASSWORD_CANCELLED", "Password update cancelled");
}
if (first !== second) {
throw createCommandError("PASSWORD_MISMATCH", "Passwords do not match");
}
return first;
}
export async function setDaemonPasswordInConfig(
newPassword: string,
options: SetPasswordOptions = {},
): Promise<SetPasswordResult> {
const paseoHome = resolveLocalPaseoHome(options.home);
const configPath = path.join(paseoHome, CONFIG_FILENAME);
const persisted = loadPersistedConfig(paseoHome);
const nextConfig: PersistedConfig = {
...persisted,
daemon: {
...persisted.daemon,
auth: {
...persisted.daemon?.auth,
password: hashDaemonPassword(newPassword),
},
},
};
savePersistedConfig(paseoHome, nextConfig);
return {
action: "password_set",
configPath,
restartCommand: "paseo daemon restart",
message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart`,
};
}
export async function runSetPasswordCommand(
options: CommandOptions,
_command: Command,
): Promise<SingleResult<SetPasswordResult>> {
const promptPassword =
typeof options.promptPassword === "function"
? (options.promptPassword as PromptPassword)
: (message: string) => passwordPrompt({ message });
const newPassword = await promptForPassword(promptPassword);
const result = await setDaemonPasswordInConfig(newPassword, {
home: typeof options.home === "string" ? options.home : undefined,
});
return {
type: "single",
data: result,
schema: setPasswordResultSchema,
};
}
+63 -10
View File
@@ -1,10 +1,14 @@
import { existsSync, readFileSync } from "node:fs";
import {
loadConfig,
resolvePaseoHome,
DaemonClient,
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
loadConfig,
normalizeHostPort,
parseConnectionUri,
parseConnectionOfferFromUrl,
DaemonClient,
resolvePaseoHome,
shouldUseTlsForDefaultHostedRelay,
type ConnectionOffer,
type WebSocketLike,
} from "@getpaseo/server";
@@ -46,6 +50,27 @@ export function normalizeDaemonHost(raw: string): string | null {
return null;
}
if (trimmed.startsWith("tcp://")) {
try {
const parsed = parseConnectionUri(trimmed);
const endpoint = normalizeHostPort(
parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`,
);
const query = new URLSearchParams();
if (parsed.useTls) {
query.set("ssl", "true");
}
if (parsed.password) {
query.set("password", parsed.password);
}
const queryString = query.toString();
const suffix = queryString ? `?${queryString}` : "";
return `tcp://${endpoint}${suffix}`;
} catch {
return null;
}
}
if (
trimmed.startsWith("unix://") ||
trimmed.startsWith("pipe://") ||
@@ -174,21 +199,37 @@ export function resolveDaemonTarget(host: string): DaemonTarget {
};
}
if (trimmed.startsWith("tcp://")) {
const parsed = parseConnectionUri(trimmed);
const endpoint = normalizeHostPort(
parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`,
);
return {
type: "tcp",
url: buildDaemonWebSocketUrl(endpoint, { useTls: parsed.useTls }),
};
}
return {
type: "tcp",
url: `ws://${trimmed}/ws`,
};
}
export function resolveDaemonPassword(host: string): string | undefined {
const trimmed = host.trim();
return trimmed.startsWith("tcp://") ? parseConnectionUri(trimmed).password : undefined;
}
/**
* Create a WebSocket factory that works in Node.js
*/
function createNodeWebSocketFactory() {
return (
url: string,
options?: { headers?: Record<string, string>; socketPath?: string },
options?: { headers?: Record<string, string>; protocols?: string[]; socketPath?: string },
): WebSocketLike => {
return new WebSocket(url, {
return new WebSocket(url, options?.protocols, {
headers: options?.headers,
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
}) as unknown as WebSocketLike;
@@ -201,6 +242,7 @@ function createNodeWebSocketFactory() {
*/
async function tryConnectHost(
host: string,
password: string | undefined,
clientId: string,
timeout: number,
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
@@ -211,10 +253,15 @@ async function tryConnectHost(
clientId,
clientType: "cli",
appVersion: resolveCliVersion(),
password,
connectTimeoutMs: timeout,
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
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 },
@@ -239,6 +286,7 @@ async function connectViaRelayOffer(
endpoint: offer.relay.endpoint,
serverId: offer.serverId,
role: "client",
useTls: shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
});
const client = new DaemonClient({
@@ -247,8 +295,10 @@ async function connectViaRelayOffer(
clientType: "cli",
appVersion: resolveCliVersion(),
connectTimeoutMs: timeout,
webSocketFactory: (target: string, config?: { headers?: Record<string, string> }) =>
nodeWebSocketFactory(target, { headers: config?.headers }),
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 },
});
@@ -293,8 +343,11 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`);
}
const host = hosts[index] as string;
const result = await tryConnectHost(host, clientId, timeout, nodeWebSocketFactory);
if ("client" in result) return result.client;
const password = resolveDaemonPassword(host);
const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory);
if ("client" in result) {
return result.client;
}
return tryNext(index + 1, result.error);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Command } from "commander";
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
const DAEMON_HOST_OPTION_DESCRIPTION =
"Daemon host target (default: local socket/pipe, then localhost:6767)";
"Daemon host target: host:port or tcp://host:port?ssl=true&password=secret (default: local socket/pipe, then localhost:6767)";
export function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value]);
@@ -7,6 +7,7 @@ import path from "node:path";
import {
getDaemonHost,
normalizeDaemonHost,
resolveDaemonPassword,
resolveDaemonTarget,
resolveDefaultDaemonHosts,
} from "../src/utils/client.js";
@@ -37,20 +38,39 @@ console.log("=== CLI IPC Target Helpers ===\n");
}
{
console.log("Test 3: local unix socket paths normalize into IPC daemon targets");
console.log("Test 3: tcp URI host targets honor ssl=true");
const target = resolveDaemonTarget("tcp://example.com:6767?ssl=true&password=query-secret");
assert.deepStrictEqual(target, {
type: "tcp",
url: "wss://example.com:6767/ws",
});
console.log("✓ tcp URI host targets honor ssl=true\n");
}
{
console.log("Test 4: tcp URI hosts normalize into canonical direct TCP targets");
assert.strictEqual(
normalizeDaemonHost("tcp://Example.com:6767?ssl=true&password=query-secret"),
"tcp://Example.com:6767?ssl=true&password=query-secret",
);
console.log("✓ tcp URI hosts normalize into canonical direct TCP targets\n");
}
{
console.log("Test 5: local unix socket paths normalize into IPC daemon targets");
assert.strictEqual(normalizeDaemonHost("/tmp/paseo.sock"), "unix:///tmp/paseo.sock");
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
}
{
console.log("Test 3b: Windows absolute paths are NOT treated as unix sockets");
console.log("Test 5b: Windows absolute paths are NOT treated as unix sockets");
assert.strictEqual(normalizeDaemonHost("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
assert.strictEqual(normalizeDaemonHost("D:\\project\\socket"), null);
console.log("✓ Windows absolute paths are not treated as unix sockets\n");
}
{
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
console.log("Test 6: default host resolution tries local IPC first, then localhost fallback");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
try {
mkdirSync(paseoHome, { recursive: true });
@@ -78,7 +98,7 @@ console.log("=== CLI IPC Target Helpers ===\n");
}
{
console.log("Test 5: configured TCP host is preserved before the localhost fallback");
console.log("Test 7: configured TCP host is preserved before the localhost fallback");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-tcp-"));
try {
assert.deepStrictEqual(
@@ -95,13 +115,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
}
{
console.log("Test 6: CLI app version resolves for daemon hello compatibility");
console.log("Test 8: CLI app version resolves for daemon hello compatibility");
assert.match(resolveCliVersion(), /^\d+\.\d+\.\d+/);
console.log("✓ CLI app version resolves for daemon hello compatibility\n");
}
{
console.log("Test 7: local IPC still takes priority over configured TCP hosts");
console.log("Test 9: local IPC still takes priority over configured TCP hosts");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-order-"));
try {
mkdirSync(paseoHome, { recursive: true });
@@ -122,4 +142,15 @@ console.log("=== CLI IPC Target Helpers ===\n");
console.log("✓ local IPC still takes priority over configured TCP hosts\n");
}
{
console.log("Test 10: daemon password resolution uses only the TCP URI query");
assert.strictEqual(
resolveDaemonPassword("tcp://example.com:6767?ssl=true&password=query-secret"),
"query-secret",
);
assert.strictEqual(resolveDaemonPassword("tcp://missing.example:6767"), undefined);
assert.strictEqual(resolveDaemonPassword("example.com:6767"), undefined);
console.log("✓ daemon password resolution uses only the TCP URI query\n");
}
console.log("=== All CLI IPC target tests passed ===");
@@ -0,0 +1,107 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Command } from "commander";
import { isBearerTokenValid } from "@getpaseo/server";
import {
runSetPasswordCommand,
setDaemonPasswordInConfig,
type PromptPassword,
} from "../src/commands/daemon/set-password.ts";
console.log("=== Daemon Set Password Command ===\n");
const root = await mkdtemp(join(tmpdir(), "paseo-set-password-"));
const paseoHome = join(root, ".paseo");
function promptSequence(values: string[]): PromptPassword {
return async () => {
const value = values.shift();
if (value === undefined) {
throw new Error("prompt called too many times");
}
return value;
};
}
try {
{
console.log("Test 1: setDaemonPasswordInConfig writes hash and preserves config fields");
await mkdir(paseoHome, { recursive: true });
await writeFile(
join(paseoHome, "config.json"),
`${JSON.stringify(
{
version: 1,
daemon: {
listen: "127.0.0.1:9999",
relay: { enabled: false },
},
app: { baseUrl: "https://app.paseo.sh" },
},
null,
2,
)}\n`,
);
const result = await setDaemonPasswordInConfig("shared-secret", { home: paseoHome });
const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf-8"));
assert.strictEqual(result.configPath, join(paseoHome, "config.json"));
assert.strictEqual(result.restartCommand, "paseo daemon restart");
assert.strictEqual(config.daemon.listen, "127.0.0.1:9999");
assert.strictEqual(config.daemon.relay.enabled, false);
assert.notStrictEqual(config.daemon.auth.password, "shared-secret");
assert.match(config.daemon.auth.password, /^\$2[aby]\$12\$/);
assert.strictEqual(
isBearerTokenValid({ password: config.daemon.auth.password, token: "shared-secret" }),
true,
);
console.log("✓ set-password writes bcrypt hash without clobbering config\n");
}
{
console.log("Test 2: command prompts twice and accepts matching confirmation");
const result = await runSetPasswordCommand(
{
home: paseoHome,
promptPassword: promptSequence(["new-secret", "new-secret"]),
},
{} as Command,
);
const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf-8"));
assert.strictEqual(result.data.action, "password_set");
assert.strictEqual(
isBearerTokenValid({ password: config.daemon.auth.password, token: "new-secret" }),
true,
);
console.log("✓ command accepts matching confirmation\n");
}
{
console.log("Test 3: command refuses mismatched confirmation");
await assert.rejects(
runSetPasswordCommand(
{
home: paseoHome,
promptPassword: promptSequence(["first-secret", "second-secret"]),
},
{} as Command,
),
(error: unknown) =>
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "PASSWORD_MISMATCH",
);
console.log("✓ command refuses password mismatch\n");
}
} finally {
await rm(root, { recursive: true, force: true });
}
console.log("=== Daemon Set Password Command Tests Passed ===");
+1
View File
@@ -70,6 +70,7 @@
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.17.1",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
@@ -10,11 +10,12 @@ export interface DaemonTransport {
export type DaemonTransportFactory = (options: {
url: string;
headers?: Record<string, string>;
protocols?: string[];
}) => DaemonTransport;
export type WebSocketFactory = (
url: string,
options?: { headers?: Record<string, string> },
options?: { headers?: Record<string, string>; protocols?: string[] },
) => WebSocketLike;
export interface WebSocketLike {
@@ -75,6 +75,27 @@ describe("daemon-client transport helpers", () => {
expect(close).toHaveBeenCalledWith(1000, "bye");
});
test("createWebSocketTransportFactory passes WebSocket protocols to the socket factory", () => {
const socketFactory = vi.fn(() => ({
readyState: 1,
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
createWebSocketTransportFactory(socketFactory)({
url: "ws://example.test",
headers: { Authorization: "Bearer shared-secret" },
protocols: ["paseo.bearer.shared-secret"],
});
expect(socketFactory).toHaveBeenCalledWith("ws://example.test", {
headers: { Authorization: "Bearer shared-secret" },
protocols: ["paseo.bearer.shared-secret"],
});
});
test("createWebSocketTransportFactory rejects sends when socket is not open", () => {
const factory = createWebSocketTransportFactory(() => ({
readyState: 3,
@@ -6,18 +6,22 @@ import type {
export function defaultWebSocketFactory(
url: string,
_options?: { headers?: Record<string, string> },
options?: { headers?: Record<string, string>; protocols?: string[] },
): WebSocketLike {
const globalWs = (globalThis as { WebSocket?: new (url: string) => WebSocketLike }).WebSocket;
const globalWs = (
globalThis as {
WebSocket?: new (url: string, protocols?: string | string[]) => WebSocketLike;
}
).WebSocket;
if (!globalWs) {
throw new Error("WebSocket is not available in this runtime");
}
return new globalWs(url);
return new globalWs(url, options?.protocols);
}
export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory {
return ({ url, headers }) => {
const ws = factory(url, { headers });
return ({ url, headers, protocols }) => {
const ws = factory(url, { headers, protocols });
if ("binaryType" in ws) {
try {
ws.binaryType = "arraybuffer";
@@ -184,6 +184,32 @@ test("dedupes in-flight checkout status requests per agentId", async () => {
});
});
test("passes password as HTTP bearer header and WebSocket subprotocol", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const transportFactory = vi.fn(() => mock.transport);
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
password: "shared-secret",
logger,
reconnect: { enabled: false },
transportFactory,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
expect(transportFactory).toHaveBeenCalledWith({
url: "ws://test",
headers: { Authorization: "Bearer shared-secret" },
protocols: ["paseo.bearer.shared-secret"],
});
});
test("does not reconnect after close when ensureConnected is called", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+19 -3
View File
@@ -129,6 +129,13 @@ export interface ImportAgentInput {
labels?: Record<string, string>;
}
function normalizePassword(value: string | undefined): string | null {
if (typeof value !== "string") {
return null;
}
return value.length > 0 ? value : null;
}
export type {
DaemonTransport,
DaemonTransportFactory,
@@ -198,6 +205,7 @@ export interface DaemonClientConfig {
clientType?: "mobile" | "browser" | "cli" | "mcp";
appVersion?: string;
runtimeGeneration?: number | null;
password?: string;
authHeader?: string;
suppressSendErrors?: boolean;
transportFactory?: DaemonTransportFactory;
@@ -786,9 +794,13 @@ export class DaemonClient {
}
const headers: Record<string, string> = {};
if (this.config.authHeader) {
headers["Authorization"] = this.config.authHeader;
const password = normalizePassword(this.config.password);
if (password) {
headers.Authorization = `Bearer ${password}`;
} else if (this.config.authHeader) {
headers.Authorization = this.config.authHeader;
}
const protocols = password ? [`paseo.bearer.${password}`] : undefined;
try {
// Reconnect can overlap with browser close/error delivery ordering.
@@ -813,7 +825,11 @@ export class DaemonClient {
});
}
const transportUrl = this.resolveTransportUrlForAttempt();
const transport = transportFactory({ url: transportUrl, headers });
const transport = transportFactory({
url: transportUrl,
headers,
...(protocols ? { protocols } : {}),
});
this.transport = transport;
this.lastServerInfoMessage = null;
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, test } from "vitest";
import {
extractHttpBearerToken,
extractWsBearerProtocol,
extractWsBearerToken,
hashDaemonPassword,
isBearerTokenValidAsync,
isBearerTokenValid,
} from "./auth.js";
const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
describe("daemon bearer validator", () => {
test("allows any token when no password is configured", () => {
expect(isBearerTokenValid({ password: undefined, token: null })).toBe(true);
expect(isBearerTokenValid({ password: undefined, token: "anything" })).toBe(true);
});
test("accepts the plaintext token against the bcrypt hash and rejects missing or wrong tokens", async () => {
expect(
await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: "correct-password" }),
).toBe(true);
expect(isBearerTokenValid({ password: CORRECT_PASSWORD_HASH, token: "correct-password" })).toBe(
true,
);
expect(await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: null })).toBe(
false,
);
expect(await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: "wrong" })).toBe(
false,
);
});
test("hashes a password into a bcrypt value", () => {
const hash = hashDaemonPassword("correct-password");
expect(hash).toMatch(/^\$2[aby]\$12\$/);
expect(isBearerTokenValid({ password: hash, token: "correct-password" })).toBe(true);
});
test("extracts HTTP bearer tokens", () => {
expect(extractHttpBearerToken("Bearer secret")).toBe("secret");
expect(extractHttpBearerToken("Basic secret")).toBeNull();
expect(extractHttpBearerToken(undefined)).toBeNull();
});
test("extracts WebSocket paseo bearer subprotocol tokens", () => {
const protocol = extractWsBearerProtocol("chat, paseo.bearer.secret.with.dots");
expect(protocol).toBe("paseo.bearer.secret.with.dots");
expect(extractWsBearerToken(protocol)).toBe("secret.with.dots");
expect(extractWsBearerToken("paseo.other.secret")).toBeNull();
});
});
+127
View File
@@ -0,0 +1,127 @@
import { compare, compareSync, hashSync } from "bcryptjs";
import type { RequestHandler } from "express";
export const DAEMON_PASSWORD_BCRYPT_COST = 12;
export interface DaemonAuthConfig {
password?: string;
}
export interface BearerAuthRejectContext {
path: string;
method: string;
hasToken: boolean;
}
interface BearerValidationInput {
password: string | undefined;
token: string | null;
}
export function isBearerTokenValid(input: BearerValidationInput): boolean {
return isBearerTokenValidSync(input);
}
export async function isBearerTokenValidAsync(input: BearerValidationInput): Promise<boolean> {
if (!input.password) {
return true;
}
if (input.token === null) {
return false;
}
return compare(input.token, input.password);
}
export function isBearerTokenValidSync(input: BearerValidationInput): boolean {
if (!input.password) {
return true;
}
if (input.token === null) {
return false;
}
return compareSync(input.token, input.password);
}
export function hashDaemonPassword(password: string): string {
return hashSync(password, DAEMON_PASSWORD_BCRYPT_COST);
}
export function extractHttpBearerToken(value: string | undefined): string | null {
if (!value) {
return null;
}
const [scheme, ...tokenParts] = value.trim().split(/\s+/);
if (scheme !== "Bearer" || tokenParts.length !== 1) {
return null;
}
return tokenParts[0] ?? null;
}
export function extractWsBearerProtocol(value: string | undefined): string | null {
if (!value) {
return null;
}
for (const protocol of value.split(",")) {
const trimmed = protocol.trim();
const segments = trimmed.split(".");
if (segments[0] === "paseo" && segments[1] === "bearer" && segments.length >= 3) {
return trimmed;
}
}
return null;
}
export function extractWsBearerToken(protocol: string | null): string | null {
if (!protocol) {
return null;
}
const segments = protocol.split(".");
if (segments[0] !== "paseo" || segments[1] !== "bearer" || segments.length < 3) {
return null;
}
return segments.slice(2).join(".");
}
export function createRequireBearerMiddleware(
auth: DaemonAuthConfig | undefined,
onReject?: (context: BearerAuthRejectContext) => void,
): RequestHandler {
const password = auth?.password;
return (req, res, next) => {
if (!password || shouldBypassBearerAuth(req.method, req.path)) {
next();
return;
}
void (async () => {
try {
const token = extractHttpBearerToken(req.header("authorization"));
if (!(await isBearerTokenValidAsync({ password, token }))) {
onReject?.({
path: req.path,
method: req.method,
hasToken: token !== null,
});
res.status(401).json({ error: "Unauthorized" });
return;
}
next();
} catch (error) {
next(error);
}
})();
};
}
export function shouldBypassBearerAuth(method: string, path: string): boolean {
if (method === "OPTIONS") {
return true;
}
// Public liveness/version endpoints used by local supervisors and health probes.
return path === "/api/health" || path === "/api/status";
}
@@ -0,0 +1,133 @@
import { WebSocket } from "ws";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
const originalEnv = { ...process.env };
const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
function connectWebSocket(params: {
port: number;
protocol?: string;
}): Promise<{ ws: WebSocket; protocol: string }> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(
`ws://127.0.0.1:${params.port}/ws`,
params.protocol ? [params.protocol] : undefined,
);
ws.once("open", () => resolve({ ws, protocol: ws.protocol }));
ws.once("error", reject);
});
}
async function expectWebSocketCloses(params: {
port: number;
protocol?: string;
code: number;
reason: string;
}): Promise<void> {
const { ws } = await connectWebSocket(params);
await expect(
new Promise<{ code: number; reason: string }>((resolve) => {
ws.once("close", (code, reason) => {
resolve({ code, reason: reason.toString() });
});
}),
).resolves.toEqual({
code: params.code,
reason: params.reason,
});
}
describe("daemon bearer auth", () => {
beforeEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
process.env = { ...originalEnv, PASEO_SUPERVISED: "0" };
});
test("leaves HTTP and WebSocket open when no password is configured", async () => {
const daemonHandle = await createTestPaseoDaemon();
try {
const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`);
expect(response.status).toBe(200);
const { ws, protocol } = await connectWebSocket({ port: daemonHandle.port });
expect(protocol).toBe("");
ws.close();
} finally {
await daemonHandle.close();
}
});
test("requires Authorization bearer on protected HTTP routes when password is configured", async () => {
const daemonHandle = await createTestPaseoDaemon({
auth: { password: CORRECT_PASSWORD_HASH },
});
try {
const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`);
expect(missing.status).toBe(401);
const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, {
headers: { Authorization: "Bearer wrong-password" },
});
expect(wrong.status).toBe(401);
const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, {
headers: { Authorization: "Bearer correct-password" },
});
expect(correct.status).toBe(400);
} finally {
await daemonHandle.close();
}
});
test("bypasses bearer auth for preflight and liveness endpoints", async () => {
const daemonHandle = await createTestPaseoDaemon({
auth: { password: CORRECT_PASSWORD_HASH },
});
try {
const preflight = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, {
method: "OPTIONS",
headers: { Origin: "https://app.paseo.sh" },
});
expect(preflight.status).toBe(204);
const health = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/health`);
expect(health.status).toBe(200);
const status = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`);
expect(status.status).toBe(200);
} finally {
await daemonHandle.close();
}
});
test("closes WebSocket connections with readable auth failures when password is configured", async () => {
const daemonHandle = await createTestPaseoDaemon({
auth: { password: CORRECT_PASSWORD_HASH },
});
try {
await expectWebSocketCloses({
port: daemonHandle.port,
code: 4401,
reason: "Password required",
});
await expectWebSocketCloses({
port: daemonHandle.port,
protocol: "paseo.bearer.wrong-password",
code: 4401,
reason: "Incorrect password",
});
const { ws, protocol } = await connectWebSocket({
port: daemonHandle.port,
protocol: "paseo.bearer.correct-password",
});
expect(protocol).toBe("paseo.bearer.correct-password");
ws.close();
} finally {
await daemonHandle.close();
}
});
});
@@ -118,6 +118,8 @@ describe("paseo daemon bootstrap", () => {
statusText: "test cleanup",
}),
);
vi.unstubAllGlobals();
globalThis.fetch = originalFetch;
await daemonHandle.close();
}
});
+23 -7
View File
@@ -135,6 +135,7 @@ import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
import { createRequireBearerMiddleware, type DaemonAuthConfig } from "./auth.js";
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
@@ -190,6 +191,7 @@ export interface PaseoDaemonConfig {
relayEndpoint?: string;
relayPublicEndpoint?: string;
appBaseUrl?: string;
auth?: DaemonAuthConfig;
openai?: PaseoOpenAIConfig;
speech?: PaseoSpeechConfig;
voiceLlmProvider?: AgentProvider | null;
@@ -294,12 +296,6 @@ export async function createPaseoDaemon(
});
}
// Script proxy — intercepts requests for registered *.localhost hostnames
// and forwards them to the corresponding local script port. Placed after
// the host allowlist (*.localhost is already allowed) but before CORS and
// the rest of the routes so proxied requests skip unnecessary middleware.
app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
// CORS - allow same-origin + configured origins
const allowedOrigins = new Set([
...config.corsAllowedOrigins,
@@ -330,6 +326,17 @@ export async function createPaseoDaemon(
next();
});
app.use(
createRequireBearerMiddleware(config.auth, (context) => {
logger.warn(context, "Rejected HTTP request with invalid daemon password");
}),
);
// Script proxy — intercepts requests for registered *.localhost hostnames
// and forwards them to the corresponding local script port. Placed after
// host/CORS/auth checks but before the rest of the routes.
app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
// Serve static files from public directory
app.use("/public", express.static(staticDir));
@@ -771,16 +778,24 @@ export async function createPaseoDaemon(
{
host: boundListenTarget.host,
port: boundListenTarget.port,
authRequired: !!config.auth?.password,
elapsed: elapsed(),
},
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
);
} else {
logger.info(
{ path: boundListenTarget.path, elapsed: elapsed() },
{
path: boundListenTarget.path,
authRequired: !!config.auth?.password,
elapsed: elapsed(),
},
`Server listening on ${boundListenTarget.path}`,
);
}
if (config.auth?.password) {
logger.info("Daemon password authentication enabled");
}
wsServer = new VoiceAssistantWebSocketServer(
httpServer,
@@ -793,6 +808,7 @@ export async function createPaseoDaemon(
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, hostnames: configuredHostnames },
config.auth,
speechService,
terminalManager,
{
@@ -0,0 +1,58 @@
import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { loadConfig } from "./config.js";
import { isBearerTokenValid } from "./auth.js";
const roots: string[] = [];
const CONFIG_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
async function createPaseoHome(config: unknown): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-config-auth-"));
roots.push(root);
const paseoHome = path.join(root, ".paseo");
await mkdir(paseoHome, { recursive: true });
await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2));
return paseoHome;
}
describe("daemon auth config", () => {
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("loads optional auth password hash from config.json", async () => {
const paseoHome = await createPaseoHome({
version: 1,
daemon: {
auth: { password: CONFIG_PASSWORD_HASH },
},
});
const config = loadConfig(paseoHome, { env: {} });
expect(config.auth?.password).toBe(CONFIG_PASSWORD_HASH);
expect(isBearerTokenValid({ password: config.auth?.password, token: "correct-password" })).toBe(
true,
);
});
test("lets PASEO_PASSWORD override config.json auth password hash", async () => {
const paseoHome = await createPaseoHome({
version: 1,
daemon: {
auth: { password: CONFIG_PASSWORD_HASH },
},
});
const config = loadConfig(paseoHome, {
env: { PASEO_PASSWORD: "from-env" },
});
expect(config.auth?.password).not.toBe(CONFIG_PASSWORD_HASH);
expect(config.auth?.password).toMatch(/^\$2[aby]\$12\$/);
expect(isBearerTokenValid({ password: config.auth?.password, token: "from-env" })).toBe(true);
});
});
+15
View File
@@ -11,6 +11,7 @@ import type {
} from "./agent/provider-launch-config.js";
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
import { AgentProviderSchema } from "./agent/provider-manifest.js";
import { hashDaemonPassword } from "./auth.js";
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
@@ -184,6 +185,19 @@ function resolveListenAddress(
);
}
function resolveAuthConfig(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): PaseoDaemonConfig["auth"] {
const envPassword = env.PASEO_PASSWORD?.trim();
if (envPassword) {
return { password: hashDaemonPassword(envPassword) };
}
return persisted.daemon?.auth?.password
? { password: persisted.daemon.auth.password }
: undefined;
}
function resolveStaticLoadConfigSettings(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
@@ -249,6 +263,7 @@ export function loadConfig(
relayEndpoint: relay.endpoint,
relayPublicEndpoint: relay.publicEndpoint,
appBaseUrl,
auth: resolveAuthConfig(env, persisted),
openai,
speech,
voiceLlmProvider: voiceLlm.provider,
@@ -12,6 +12,7 @@ import {
type DaemonTestContext,
DaemonClient,
} from "./test-utils/index.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
import type {
@@ -74,6 +75,52 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-client-"));
}
test("DaemonClient connects to a password-protected daemon", async () => {
const daemon = await createTestPaseoDaemon({
auth: { password: "$2b$12$GMhF7pN4QnMlHOQXOqjd1OitKWPSmAO3FwB0PHzKtcZR/sAMryz76" },
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
password: "shared-secret",
});
try {
await client.connect();
const agents = await client.fetchAgents();
expect(agents.entries).toEqual([]);
} finally {
await client.close();
await daemon.close();
}
});
test("DaemonClient surfaces password auth failures from WebSocket close reasons", async () => {
const daemon = await createTestPaseoDaemon({
auth: { password: "$2b$12$GMhF7pN4QnMlHOQXOqjd1OitKWPSmAO3FwB0PHzKtcZR/sAMryz76" },
});
const missingPasswordClient = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
reconnect: { enabled: false },
});
const wrongPasswordClient = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
password: "wrong-secret",
reconnect: { enabled: false },
});
try {
await expect(missingPasswordClient.connect()).rejects.toThrow("Password required");
expect(missingPasswordClient.lastError).toBe("Password required");
await expect(wrongPasswordClient.connect()).rejects.toThrow("Incorrect password");
expect(wrongPasswordClient.lastError).toBe("Incorrect password");
} finally {
await missingPasswordClient.close();
await wrongPasswordClient.close();
await daemon.close();
}
});
function waitForSignal<T>(
timeoutMs: number,
setup: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void,
@@ -106,6 +106,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const url = buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${port}`,
useTls: false,
serverId,
role: "server",
});
@@ -225,6 +226,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
useTls: false,
serverId,
role: "client",
}),
@@ -392,6 +394,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
useTls: false,
serverId,
role: "client",
}),
+18 -1
View File
@@ -4,7 +4,12 @@ export { loadConfig, type CliConfigOverrides } from "./config.js";
export { resolvePaseoHome } from "./paseo-home.js";
export { getOrCreateServerId } from "./server-id.js";
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js";
export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
export {
loadPersistedConfig,
savePersistedConfig,
type PersistedConfig,
} from "./persisted-config.js";
export { hashDaemonPassword, isBearerTokenValid } from "./auth.js";
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
export {
ConnectionOfferSchema,
@@ -20,6 +25,18 @@ export {
type DaemonEvent,
} from "../client/daemon-client.js";
export type { WebSocketFactory, WebSocketLike } from "../client/daemon-client-transport-types.js";
export {
buildDaemonWebSocketUrl,
deriveLabelFromEndpoint,
normalizeHostPort,
parseConnectionUri,
shouldUseTlsForDefaultHostedRelay,
} from "../shared/daemon-endpoints.js";
export {
DirectTcpHostConnectionSchema,
type DirectTcpHostConnection,
type NormalizedDirectTcpHostConnection,
} from "../shared/host-connection-schema.js";
export {
ensureLocalSpeechModels,
listLocalSpeechModels,
+15 -1
View File
@@ -54,6 +54,20 @@ const DEFAULT_FILE_LEVEL: LogLevel = "debug";
const DEFAULT_FILE_ROTATE_SIZE = "10m";
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
const REDACT_PATHS = [
"authorization",
"Authorization",
"headers.authorization",
"headers.Authorization",
"req.headers.authorization",
"req.headers.Authorization",
'["sec-websocket-protocol"]',
"Sec-WebSocket-Protocol",
'headers["sec-websocket-protocol"]',
"headers.Sec-WebSocket-Protocol",
'req.headers["sec-websocket-protocol"]',
"req.headers.Sec-WebSocket-Protocol",
];
function parseLogLevel(value: string | undefined): LogLevel | undefined {
if (!value || !LOG_LEVELS.has(value as LogLevel)) {
@@ -285,7 +299,7 @@ export function createRootLogger(
});
return pino(
{ level: config.level },
{ level: config.level, redact: { paths: REDACT_PATHS, remove: true } },
pino.multistream([
{ level: config.console.level, stream: consoleStream },
{ level: config.file.level, stream: fileStream },
@@ -2,6 +2,19 @@ import { describe, expect, test } from "vitest";
import { PersistedConfigSchema } from "./persisted-config.js";
describe("PersistedConfigSchema daemon auth config", () => {
test("accepts optional daemon password hash", () => {
const hash = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
const parsed = PersistedConfigSchema.parse({
daemon: {
auth: { password: hash },
},
});
expect(parsed.daemon?.auth?.password).toBe(hash);
});
});
describe("PersistedConfigSchema agent provider runtime settings", () => {
test("legacy append entries are skipped during migration", () => {
const parsed = PersistedConfigSchema.parse({
@@ -62,6 +62,16 @@ const ProvidersSchema = z
})
.strict();
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
message: "Expected a bcrypt hash",
});
const DaemonAuthSchema = z
.object({
password: BcryptHashSchema.optional(),
})
.strict();
const SpeechProviderIdSchema = z
.string()
.trim()
@@ -251,6 +261,7 @@ export const PersistedConfigSchema = z
})
.strict()
.optional(),
auth: DaemonAuthSchema.optional(),
})
.strict()
.transform(({ allowedHosts, ...daemon }) => {
@@ -8,7 +8,10 @@ import {
type Transport as RelayTransport,
type KeyPair,
} from "@getpaseo/relay/e2ee";
import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js";
import {
buildRelayWebSocketUrl,
shouldUseTlsForDefaultHostedRelay,
} from "../shared/daemon-endpoints.js";
import type { ExternalSocketMetadata } from "./websocket-server.js";
interface RelayTransportOptions {
@@ -151,6 +154,7 @@ export function startRelayTransport({
const connectionId = ++controlConnectionSeq;
const url = buildRelayWebSocketUrl({
endpoint: relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint),
serverId,
role: "server",
});
@@ -326,6 +330,7 @@ export function startRelayTransport({
const url = buildRelayWebSocketUrl({
endpoint: relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint),
serverId,
role: "server",
connectionId,
+4
View File
@@ -1080,6 +1080,10 @@ export class Session {
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
}
async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable<string>): Promise<void> {
await Promise.all(Array.from(cwds, (cwd) => this.emitWorkspaceUpdateForCwd(cwd)));
}
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
await this.syncWorkspaceGitObserverForWorkspace(workspace);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
@@ -30,7 +30,9 @@ export class DaemonClient extends SharedDaemonClient {
...config,
clientId,
webSocketFactory: (url, options) =>
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike,
new WebSocket(url, options?.protocols, {
headers: options?.headers,
}) as unknown as WebSocketLike,
});
}
}
@@ -29,6 +29,7 @@ interface TestPaseoDaemonOptions {
voiceLlmProviderExplicit?: boolean;
voiceLlmModel?: string | null;
dictationFinalTimeoutMs?: number;
auth?: PaseoDaemonConfig["auth"];
}
export interface TestPaseoDaemon {
@@ -155,6 +156,7 @@ async function prepareTestDaemonConfig(
relayEnabled: options.relayEnabled ?? false,
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",
appBaseUrl: "https://app.paseo.sh",
auth: options.auth,
openai: options.openai,
speech: options.speech,
voiceLlmProvider: options.voiceLlmProvider ?? null,
@@ -107,6 +107,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
undefined,
undefined,
undefined,
undefined,
false,
"1.2.3-test",
undefined,
@@ -187,6 +187,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
daemonConfigStore as unknown as DaemonConfigStore,
null,
{ allowedOrigins: new Set() },
undefined,
speechReadiness
? {
getReadiness: () => speechReadiness,
@@ -108,6 +108,7 @@ function createServer(logger: ReturnType<typeof createLogger>) {
undefined,
undefined,
undefined,
undefined,
false,
"1.2.3-test",
undefined,
+59 -5
View File
@@ -1,4 +1,4 @@
import { WebSocketServer } from "ws";
import { WebSocket, WebSocketServer } from "ws";
import type { IncomingMessage, Server as HTTPServer } from "http";
import { basename, join } from "path";
import { hostname as getHostname } from "node:os";
@@ -52,6 +52,14 @@ import {
findLatestPermissionRequest,
} from "../shared/agent-attention-notification.js";
import { createGitHubService, type GitHubService } from "../services/github-service.js";
import {
extractWsBearerProtocol,
extractWsBearerToken,
isBearerTokenValid,
type DaemonAuthConfig,
} from "./auth.js";
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
export interface ExternalSocketMetadata {
transport: "relay";
@@ -415,6 +423,7 @@ export class VoiceAssistantWebSocketServer {
daemonConfigStore: DaemonConfigStore,
mcpBaseUrl: string | null,
wsConfig: WebSocketServerConfig,
auth?: DaemonAuthConfig,
speech?: SpeechService | null,
terminalManager?: TerminalManager | null,
dictation?: {
@@ -527,7 +536,7 @@ export class VoiceAssistantWebSocketServer {
});
});
this.wss = this.createWebSocketServer(server, wsConfig);
this.wss = this.createWebSocketServer(server, wsConfig, auth);
this.startRuntimeMetricsInterval();
this.logger.info("WebSocket server initialized on /ws");
@@ -568,17 +577,20 @@ export class VoiceAssistantWebSocketServer {
private createWebSocketServer(
server: HTTPServer,
wsConfig: WebSocketServerConfig,
auth: DaemonAuthConfig | undefined,
): WebSocketServer {
const { allowedOrigins, hostnames } = wsConfig;
const password = auth?.password;
const wss = new WebSocketServer({
server,
path: "/ws",
handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password),
verifyClient: ({ req }, callback) => {
this.verifyWsClient(req, allowedOrigins, hostnames, callback);
this.verifyWsUpgrade(req, allowedOrigins, hostnames, callback);
},
});
wss.on("connection", (ws, request) => {
void this.attachSocket(ws, request);
void this.attachAuthenticatedSocket(ws, request, password);
});
return wss;
}
@@ -591,7 +603,7 @@ export class VoiceAssistantWebSocketServer {
(runtimeMetricsInterval as unknown as { unref?: () => void }).unref?.();
}
private verifyWsClient(
private verifyWsUpgrade(
req: IncomingMessage,
allowedOrigins: Set<string>,
hostnames: HostnamesConfig | undefined,
@@ -623,6 +635,30 @@ export class VoiceAssistantWebSocketServer {
}
}
private async attachAuthenticatedSocket(
ws: WebSocket,
request: IncomingMessage,
password: string | undefined,
): Promise<void> {
if (password) {
const requestMetadata = extractSocketRequestMetadata(request);
const protocol = extractWsBearerProtocol(request.headers["sec-websocket-protocol"]);
const token = extractWsBearerToken(protocol);
const isAuthorized = isBearerTokenValid({ password, token });
if (!isAuthorized) {
const reason = token === null ? "Password required" : "Incorrect password";
this.logger.warn(
{ ...requestMetadata, hasToken: token !== null },
"Rejected WebSocket connection with invalid daemon password",
);
ws.close(WS_CLOSE_DAEMON_AUTH_FAILED, reason);
return;
}
}
await this.attachSocket(ws, request);
}
public broadcast(message: WSOutboundMessage): void {
const payload = JSON.stringify(message);
for (const ws of this.sessions.keys()) {
@@ -1840,6 +1876,24 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata {
};
}
function selectWebSocketProtocol(
protocols: Set<string>,
password: string | undefined,
): string | false {
if (!password) {
return protocols.values().next().value ?? false;
}
for (const protocol of protocols) {
const token = extractWsBearerToken(protocol);
if (token !== null) {
return protocol;
}
}
return false;
}
function stringifyCloseReason(reason: unknown): string | null {
if (typeof reason === "string") {
return reason.length > 0 ? reason : null;
@@ -1,16 +1,105 @@
import { describe, expect, test } from "vitest";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
CURRENT_RELAY_PROTOCOL_VERSION,
normalizeRelayProtocolVersion,
parseConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "./daemon-endpoints.js";
describe("connection URI parsing", () => {
test("round-trips a tcp host and port", () => {
const parsed = parseConnectionUri("tcp://localhost:6767");
expect(parsed).toEqual({
host: "localhost",
port: 6767,
isIpv6: false,
useTls: false,
});
expect(serializeConnectionUri(parsed)).toBe("tcp://localhost:6767");
});
test("round-trips an SSL-enabled tcp host and port", () => {
const parsed = parseConnectionUri("tcp://example.com:443?ssl=true");
expect(parsed).toEqual({
host: "example.com",
port: 443,
isIpv6: false,
useTls: true,
});
expect(serializeConnectionUri(parsed)).toBe("tcp://example.com:443?ssl=true");
});
test("round-trips an IPv6 host", () => {
const parsed = parseConnectionUri("tcp://[::1]:6767?ssl=true");
expect(parsed).toEqual({
host: "::1",
port: 6767,
isIpv6: true,
useTls: true,
});
expect(serializeConnectionUri(parsed)).toBe("tcp://[::1]:6767?ssl=true");
});
test("rejects a missing port", () => {
expect(() => parseConnectionUri("tcp://localhost")).toThrow("Connection URI port is required");
});
test("rejects an invalid scheme", () => {
expect(() => parseConnectionUri("http://localhost:6767")).toThrow(
"Connection URI protocol must be tcp:",
);
});
test("parses password without including it in the public serializer", () => {
const parsed = parseConnectionUri("tcp://localhost:6767?ssl=true&password=secret");
expect(parsed).toEqual({
host: "localhost",
port: 6767,
isIpv6: false,
useTls: true,
password: "secret",
});
expect(serializeConnectionUri(parsed)).toBe("tcp://localhost:6767?ssl=true");
expect(serializeConnectionUriForStorage(parsed)).toBe(
"tcp://localhost:6767?ssl=true&password=secret",
);
});
test("rejects userinfo passwords", () => {
expect(() => parseConnectionUri("tcp://:secret@localhost:6767?ssl=true")).toThrow(
"Connection URI userinfo is not supported",
);
});
});
describe("daemon websocket URLs", () => {
test("uses ws for port 443 when TLS is disabled", () => {
expect(buildDaemonWebSocketUrl("example.com:443", { useTls: false })).toBe(
"ws://example.com:443/ws",
);
});
test("uses wss for non-443 ports when TLS is enabled", () => {
expect(buildDaemonWebSocketUrl("example.com:6767", { useTls: true })).toBe(
"wss://example.com:6767/ws",
);
});
});
describe("relay websocket URL versioning", () => {
test("defaults relay URLs to v2", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
useTls: true,
serverId: "srv_test",
role: "client",
}),
@@ -24,6 +113,7 @@ describe("relay websocket URL versioning", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
useTls: true,
serverId: "srv_test",
role: "server",
connectionId: "conn_abc123",
@@ -37,6 +127,7 @@ describe("relay websocket URL versioning", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
useTls: true,
serverId: "srv_test",
role: "server",
version: "1",
@@ -55,3 +146,31 @@ describe("relay websocket URL versioning", () => {
expect(() => normalizeRelayProtocolVersion("3")).toThrow('Relay version must be "1" or "2"');
});
});
describe("relay websocket URLs", () => {
test("uses ws for port 443 when TLS is disabled", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:443",
useTls: false,
serverId: "srv_test",
role: "client",
}),
);
expect(url.protocol).toBe("ws:");
});
test("uses wss for non-443 ports when TLS is enabled", () => {
const url = new URL(
buildRelayWebSocketUrl({
endpoint: "relay.paseo.sh:6767",
useTls: true,
serverId: "srv_test",
role: "client",
}),
);
expect(url.protocol).toBe("wss:");
});
});
+83 -5
View File
@@ -4,10 +4,19 @@ export interface HostPortParts {
isIpv6: boolean;
}
export interface ConnectionUriParts extends HostPortParts {
useTls: boolean;
}
export interface ParsedConnectionUri extends ConnectionUriParts {
password?: string;
}
export type RelayRole = "server" | "client";
export type RelayProtocolVersion = "1" | "2";
export const CURRENT_RELAY_PROTOCOL_VERSION: RelayProtocolVersion = "2";
export const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
export function normalizeRelayProtocolVersion(
value: unknown,
@@ -73,6 +82,66 @@ export function normalizeHostPort(input: string): string {
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
}
export function parseConnectionUri(input: string): ParsedConnectionUri {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Connection URI is required");
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new Error("Invalid connection URI");
}
if (parsed.protocol !== "tcp:") {
throw new Error("Connection URI protocol must be tcp:");
}
if (!parsed.hostname) {
throw new Error("Connection URI host is required");
}
if (!parsed.port) {
throw new Error("Connection URI port is required");
}
if (parsed.username || parsed.password) {
throw new Error("Connection URI userinfo is not supported");
}
const isIpv6 = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]");
const host = isIpv6 ? parsed.hostname.slice(1, -1) : parsed.hostname;
const password = parsed.searchParams.get("password") || undefined;
return {
host,
port: parsePort(parsed.port, "Invalid connection URI"),
isIpv6,
useTls: parsed.searchParams.get("ssl") === "true",
...(password ? { password } : {}),
};
}
export function serializeConnectionUri(parts: ConnectionUriParts): string {
return createConnectionUri(parts).toString();
}
export function serializeConnectionUriForStorage(parts: ParsedConnectionUri): string {
const url = createConnectionUri(parts);
if (parts.password) {
url.searchParams.set("password", parts.password);
}
return url.toString();
}
function createConnectionUri(parts: ConnectionUriParts): URL {
const hostPart = parts.isIpv6 ? `[${parts.host}]` : parts.host;
const url = new URL(`tcp://${hostPart}:${parts.port}`);
if (parts.useTls) {
url.searchParams.set("ssl", "true");
}
return url;
}
export function normalizeLoopbackToLocalhost(endpoint: string): string {
const { host, port, isIpv6 } = parseHostPort(endpoint);
if (host === "127.0.0.1" || (!isIpv6 && host === "0.0.0.0")) {
@@ -93,19 +162,20 @@ export function deriveLabelFromEndpoint(endpoint: string): string {
}
}
function shouldUseSecureWebSocket(port: number): boolean {
return port === 443;
export interface WebSocketUrlOptions {
useTls: boolean;
}
export function buildDaemonWebSocketUrl(endpoint: string): string {
export function buildDaemonWebSocketUrl(endpoint: string, opts: WebSocketUrlOptions): string {
const { host, port, isIpv6 } = parseHostPort(endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
const protocol = opts.useTls ? "wss" : "ws";
const hostPart = isIpv6 ? `[${host}]` : host;
return new URL(`${protocol}://${hostPart}:${port}/ws`).toString();
}
export function buildRelayWebSocketUrl(params: {
endpoint: string;
useTls: boolean;
serverId: string;
role: RelayRole;
/**
@@ -116,7 +186,7 @@ export function buildRelayWebSocketUrl(params: {
version?: RelayProtocolVersion | 1 | 2;
}): string {
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
const protocol = params.useTls ? "wss" : "ws";
const hostPart = isIpv6 ? `[${host}]` : host;
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
url.searchParams.set("serverId", params.serverId);
@@ -128,6 +198,14 @@ export function buildRelayWebSocketUrl(params: {
return url.toString();
}
export function shouldUseTlsForDefaultHostedRelay(endpoint: string): boolean {
try {
return normalizeHostPort(endpoint) === DEFAULT_RELAY_ENDPOINT;
} catch {
return false;
}
}
export function extractHostPortFromWebSocketUrl(wsUrl: string): string {
const parsed = new URL(wsUrl);
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
@@ -0,0 +1,12 @@
import { z } from "zod";
export const DirectTcpHostConnectionSchema = z.object({
id: z.string(),
type: z.literal("directTcp"),
endpoint: z.string(),
useTls: z.boolean().optional().default(false),
password: z.string().optional(),
});
export type DirectTcpHostConnection = z.input<typeof DirectTcpHostConnectionSchema>;
export type NormalizedDirectTcpHostConnection = z.output<typeof DirectTcpHostConnectionSchema>;