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
@@ -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>;