Make relay opt-in when pairing devices (#2706)
* feat: make relay opt-in for device pairing * fix(relay): keep pairing state authoritative * fix(relay): refresh pairing state across transports * fix(onboard): honor explicit relay opt-out * fix(relay): surface transport startup failures * fix(pairing): gate offers for older daemons * test(cli): rely on real pairing transport coverage * fix(cli): report live relay without a pid file * fix(relay): synchronize config during daemon startup * fix(relay): preserve mutability for ignored overrides * fix(cli): use reachable daemon for pairing * fix(app): redirect invalid pair device routes * fix(pairing): support remote host device pairing Keep Pair Device host-relative through the existing settings and daemon RPC flow. Route CLI output through an injected adapter so workflow tests do not mutate process globals. * fix(pairing): validate pairing destinations Expose the host-relative Pair Device flow on every supported app platform. Reject a reachable daemon whose identity does not match the requested Paseo home before reading or mutating its pairing state. * fix(pairing): render QR codes across platforms Generate SVG text without DOM or canvas and render it through the existing cross-platform SVG stack. * fix(cli): pair from persisted relay state Allow explicit relay pairing while the daemon is stopped when relay is already enabled in persisted configuration.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import os from "node:os";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
@@ -12,8 +12,18 @@ import { hashDaemonPassword } from "./auth.js";
|
||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { findFreePort } from "./service-proxy.js";
|
||||
import type {
|
||||
HubEnrollment,
|
||||
HubEnrollmentResult,
|
||||
HubRelationshipRemote,
|
||||
HubRevocation,
|
||||
HubSocketConnection,
|
||||
HubSocketCredentials,
|
||||
HubSocketEvents,
|
||||
} from "./hub/relationship-remote.js";
|
||||
|
||||
interface HeldAgentClose {
|
||||
started: Promise<void>;
|
||||
@@ -224,6 +234,103 @@ describe("paseo daemon bootstrap", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("relay config changes during Hub enrollment reach the live runtime", async () => {
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-startup-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(paseoHome, "hub-relationship.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
state: "pending",
|
||||
relationship: {
|
||||
daemonId: "daemon-startup-race",
|
||||
idempotencyKey: "enrollment-startup-race",
|
||||
hubOrigin: "https://hub.test",
|
||||
createdAt: "2026-07-31T00:00:00.000Z",
|
||||
scopes: ["hub.execution.*"],
|
||||
},
|
||||
credential: { secret: "credential" },
|
||||
enrollment: { token: "enrollment-token" },
|
||||
identity: { serverId: "server-startup-race", daemonPublicKey: "public-key" },
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
let markEnrollmentStarted: () => void = () => undefined;
|
||||
const enrollmentStarted = new Promise<void>((resolve) => {
|
||||
markEnrollmentStarted = resolve;
|
||||
});
|
||||
let releaseEnrollment: () => void = () => undefined;
|
||||
const enrollmentReleased = new Promise<void>((resolve) => {
|
||||
releaseEnrollment = resolve;
|
||||
});
|
||||
const remote: HubRelationshipRemote = {
|
||||
async enroll(input: HubEnrollment): Promise<HubEnrollmentResult> {
|
||||
markEnrollmentStarted();
|
||||
await enrollmentReleased;
|
||||
return {
|
||||
daemonId: input.daemonId,
|
||||
scopes: input.scopes,
|
||||
webSocketUrl: "wss://hub.test/daemon",
|
||||
};
|
||||
},
|
||||
async revoke(_input: HubRevocation): Promise<void> {},
|
||||
openSocket(_input: HubSocketCredentials, _events: HubSocketEvents): HubSocketConnection {
|
||||
return { close: () => undefined };
|
||||
},
|
||||
};
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: "127.0.0.1:0",
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: createTestAgentClients(),
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: false,
|
||||
relayEndpoint: "127.0.0.1:9",
|
||||
relayUseTls: false,
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
openai: undefined,
|
||||
speech: undefined,
|
||||
};
|
||||
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }), {
|
||||
hubRelationshipRemote: remote,
|
||||
});
|
||||
const starting = daemon.start();
|
||||
let client: DaemonClient | null = null;
|
||||
|
||||
try {
|
||||
await enrollmentStarted;
|
||||
const listenTarget = daemon.getListenTarget();
|
||||
if (!listenTarget || listenTarget.type !== "tcp") {
|
||||
throw new Error("Expected daemon TCP listener during Hub enrollment");
|
||||
}
|
||||
client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${listenTarget.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
await client.connect();
|
||||
await client.patchDaemonConfig({ relay: { enabled: true } });
|
||||
releaseEnrollment();
|
||||
await starting;
|
||||
|
||||
const status = await client.getDaemonStatus();
|
||||
expect(status.relay?.enabled).toBe(true);
|
||||
} finally {
|
||||
releaseEnrollment();
|
||||
await starting.catch(() => undefined);
|
||||
await client?.close().catch(() => undefined);
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stops new connections and agent registrations before closing agents", async () => {
|
||||
const shutdown = await beginDaemonShutdownWithAgentClosing();
|
||||
try {
|
||||
|
||||
@@ -159,9 +159,8 @@ import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js";
|
||||
import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js";
|
||||
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
|
||||
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
|
||||
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
||||
import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js";
|
||||
import type { PushNotificationSender } from "./push/notifications.js";
|
||||
import { getOrCreateServerId } from "./server-id.js";
|
||||
import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
@@ -395,6 +394,7 @@ export interface PaseoDaemonConfig {
|
||||
agentClients: Partial<Record<AgentProvider, AgentClient>>;
|
||||
agentStoragePath: string;
|
||||
relayEnabled?: boolean;
|
||||
relayEnabledMutable?: boolean;
|
||||
relayEndpoint?: string;
|
||||
relayPublicEndpoint?: string;
|
||||
relayUseTls?: boolean;
|
||||
@@ -449,6 +449,10 @@ export interface PaseoDaemonDependencies {
|
||||
hubRelationshipClock?: HubRelationshipClock;
|
||||
hubRelationshipRetryPolicy?: HubRelationshipRetryPolicy;
|
||||
createHubDaemonId?: () => string;
|
||||
serverFeatureOverrides?: {
|
||||
daemonStatusRpc?: boolean;
|
||||
relayConfig?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function createBootstrapManagedProcessRegistry(
|
||||
@@ -507,6 +511,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
|
||||
);
|
||||
|
||||
const initialConfig: MutableDaemonConfig = {
|
||||
relay: { enabled: config.relayEnabled ?? true },
|
||||
mcp: { injectIntoAgents: config.mcpInjectIntoAgents ?? true },
|
||||
browserTools: { enabled: config.browserToolsEnabled ?? false },
|
||||
providers,
|
||||
@@ -538,6 +543,7 @@ export async function createPaseoDaemon(
|
||||
config.paseoHome,
|
||||
createInitialMutableDaemonConfig(config),
|
||||
logger,
|
||||
{ relayEnabledMutable: config.relayEnabledMutable ?? true },
|
||||
);
|
||||
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
|
||||
const browserToolsBroker = new BrowserToolsBroker({});
|
||||
@@ -551,7 +557,7 @@ export async function createPaseoDaemon(
|
||||
void reconcileManagedProcessLedger(managedProcesses, logger).catch((error) => {
|
||||
logger.warn({ err: error }, "Failed to reconcile managed helper process ledger");
|
||||
});
|
||||
let relayTransport: RelayTransportController | null = null;
|
||||
let relayRuntime: RelayRuntime | null = null;
|
||||
|
||||
const staticDir = config.staticDir;
|
||||
const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000;
|
||||
@@ -1453,8 +1459,6 @@ export async function createPaseoDaemon(
|
||||
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
||||
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
|
||||
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
|
||||
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
|
||||
|
||||
if (boundListenTarget.type === "tcp") {
|
||||
logger.info(
|
||||
{
|
||||
@@ -1489,7 +1493,12 @@ export async function createPaseoDaemon(
|
||||
config.paseoHome,
|
||||
daemonConfigStore,
|
||||
mcpBaseUrl,
|
||||
{ allowedOrigins, hostnames: configuredHostnames },
|
||||
{
|
||||
allowedOrigins,
|
||||
hostnames: configuredHostnames,
|
||||
daemonStatusRpc: dependencies.serverFeatureOverrides?.daemonStatusRpc,
|
||||
relayConfig: dependencies.serverFeatureOverrides?.relayConfig,
|
||||
},
|
||||
workspaceAutoName,
|
||||
config.auth,
|
||||
speechService,
|
||||
@@ -1526,47 +1535,39 @@ export async function createPaseoDaemon(
|
||||
worktreesRoot: config.worktreesRoot,
|
||||
appBaseUrl: config.appBaseUrl,
|
||||
desktopManaged: config.desktopManaged === true,
|
||||
relay: {
|
||||
enabled: relayEnabled,
|
||||
endpoint: relayEndpoint,
|
||||
publicEndpoint: relayPublicEndpoint,
|
||||
useTls: relayUseTls,
|
||||
publicUseTls: relayPublicUseTls,
|
||||
},
|
||||
getRelayConfig: () =>
|
||||
relayRuntime?.getConfig() ?? {
|
||||
enabled: daemonConfigStore.get().relay?.enabled ?? relayEnabled,
|
||||
endpoint: relayEndpoint,
|
||||
publicEndpoint: relayPublicEndpoint,
|
||||
useTls: relayUseTls,
|
||||
publicUseTls: relayPublicUseTls,
|
||||
},
|
||||
},
|
||||
serviceProxyPublicBaseUrl,
|
||||
browserToolsBroker,
|
||||
hubRelationships,
|
||||
);
|
||||
relayRuntime = createRelayRuntime({
|
||||
config: {
|
||||
enabled: relayEnabled,
|
||||
endpoint: relayEndpoint,
|
||||
publicEndpoint: relayPublicEndpoint,
|
||||
useTls: relayUseTls,
|
||||
publicUseTls: relayPublicUseTls,
|
||||
},
|
||||
logger,
|
||||
attachSocket: async (ws, metadata) => {
|
||||
if (!wsServer) throw new Error("WebSocket server is not ready");
|
||||
await wsServer.attachExternalSocket(ws, metadata);
|
||||
},
|
||||
serverId,
|
||||
daemonKeyPair: daemonKeyPair.keyPair,
|
||||
});
|
||||
daemonConfigStore.onFieldChange("relay.enabled", (value) => {
|
||||
relayRuntime?.setEnabled(value === true);
|
||||
});
|
||||
await hubRelationships.start();
|
||||
|
||||
if (relayEnabled) {
|
||||
const offer = await createConnectionOfferV2({
|
||||
serverId,
|
||||
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
||||
relay: {
|
||||
endpoint: relayPublicEndpoint,
|
||||
useTls: relayPublicUseTls,
|
||||
},
|
||||
});
|
||||
|
||||
encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||
|
||||
relayTransport?.stop().catch(() => undefined);
|
||||
relayTransport = startRelayTransport({
|
||||
logger,
|
||||
attachSocket: (ws, metadata) => {
|
||||
if (!wsServer) {
|
||||
throw new Error("WebSocket server not initialized");
|
||||
}
|
||||
return wsServer.attachExternalSocket(ws, metadata);
|
||||
},
|
||||
relayEndpoint,
|
||||
relayUseTls,
|
||||
serverId,
|
||||
daemonKeyPair: daemonKeyPair.keyPair,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
logAndResolve().then(resolve, reject);
|
||||
@@ -1613,7 +1614,7 @@ export async function createPaseoDaemon(
|
||||
terminalManager.killAll();
|
||||
speechService.stop();
|
||||
await scheduleService.stop().catch(() => undefined);
|
||||
await relayTransport?.stop().catch(() => undefined);
|
||||
await relayRuntime?.stop().catch(() => undefined);
|
||||
if (wsServer) {
|
||||
await wsServer.close();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,44 @@ describe("daemon relay config", () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("preserves implicit relay-on for a legacy config without enabled", async () => {
|
||||
const home = await createPaseoHome({ version: 1, daemon: { relay: {} } });
|
||||
expect(loadConfig(home, { env: {} }).relayEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps explicit persisted relay state and marks it mutable", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: { relay: { enabled: false } },
|
||||
});
|
||||
const config = loadConfig(home, { env: {} });
|
||||
expect(config.relayEnabled).toBe(false);
|
||||
expect(config.relayEnabledMutable).toBe(true);
|
||||
});
|
||||
|
||||
test("marks environment relay overrides immutable", async () => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: { relay: { enabled: false } },
|
||||
});
|
||||
const config = loadConfig(home, { env: { PASEO_RELAY_ENABLED: "true" } });
|
||||
expect(config.relayEnabled).toBe(true);
|
||||
expect(config.relayEnabledMutable).toBe(false);
|
||||
});
|
||||
|
||||
test.each(["", "treu"])(
|
||||
"ignores invalid relay override %j without locking config",
|
||||
async (value) => {
|
||||
const home = await createPaseoHome({
|
||||
version: 1,
|
||||
daemon: { relay: { enabled: false } },
|
||||
});
|
||||
const config = loadConfig(home, { env: { PASEO_RELAY_ENABLED: value } });
|
||||
expect(config.relayEnabled).toBe(false);
|
||||
expect(config.relayEnabledMutable).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test("loads relay TLS from env, persisted config, and hosted relay fallback", async () => {
|
||||
const persistedHome = await createPaseoHome({
|
||||
version: 1,
|
||||
|
||||
@@ -187,6 +187,7 @@ interface ResolveRelayInput {
|
||||
|
||||
interface ResolvedRelay {
|
||||
enabled: boolean;
|
||||
enabledMutable: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
useTls: boolean;
|
||||
@@ -210,11 +211,11 @@ function resolveTlsFromEnv(
|
||||
}
|
||||
|
||||
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
|
||||
const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED);
|
||||
// COMPAT(relayOptInDefault): configs created before v0.2.6 may omit this field.
|
||||
// Preserve their relay-on behavior until 2027-01-31; new homes materialize false.
|
||||
const enabled =
|
||||
input.cliRelayEnabled ??
|
||||
parseBooleanEnv(input.env.PASEO_RELAY_ENABLED) ??
|
||||
input.persisted.daemon?.relay?.enabled ??
|
||||
true;
|
||||
input.cliRelayEnabled ?? environmentEnabled ?? input.persisted.daemon?.relay?.enabled ?? true;
|
||||
const endpoint =
|
||||
input.env.PASEO_RELAY_ENDPOINT ??
|
||||
input.persisted.daemon?.relay?.endpoint ??
|
||||
@@ -235,7 +236,14 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
|
||||
input.persisted.daemon?.relay?.publicUseTls,
|
||||
useTls,
|
||||
);
|
||||
return { enabled, endpoint, publicEndpoint, useTls, publicUseTls };
|
||||
return {
|
||||
enabled,
|
||||
enabledMutable: input.cliRelayEnabled === undefined && environmentEnabled === undefined,
|
||||
endpoint,
|
||||
publicEndpoint,
|
||||
useTls,
|
||||
publicUseTls,
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolvedVoiceLlm {
|
||||
@@ -506,6 +514,7 @@ export function loadConfig(
|
||||
staticDir: "public",
|
||||
agentClients: {},
|
||||
relayEnabled: relay.enabled,
|
||||
relayEnabledMutable: relay.enabledMutable,
|
||||
relayEndpoint: relay.endpoint,
|
||||
relayPublicEndpoint: relay.publicEndpoint,
|
||||
relayUseTls: relay.useTls,
|
||||
|
||||
@@ -63,6 +63,110 @@ describe("DaemonConfigStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("patch persists relay state and emits its field change", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
relay: { enabled: false },
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
});
|
||||
const changes: unknown[] = [];
|
||||
store.onFieldChange("relay.enabled", (value) => changes.push(value));
|
||||
|
||||
store.patch({ relay: { enabled: true } });
|
||||
|
||||
expect(changes).toEqual([true]);
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("rolls back config when a field transition fails", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
relay: { enabled: false },
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
});
|
||||
store.onFieldChange("relay.enabled", (enabled) => {
|
||||
if (enabled === true) {
|
||||
throw new Error("Relay transport failed to start");
|
||||
}
|
||||
});
|
||||
|
||||
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
|
||||
"Relay transport failed to start",
|
||||
);
|
||||
expect(store.get().relay?.enabled).toBe(false);
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects relay patches when a launch override owns the setting", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(
|
||||
paseoHome,
|
||||
{
|
||||
relay: { enabled: false },
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
},
|
||||
undefined,
|
||||
{ relayEnabledMutable: false },
|
||||
);
|
||||
|
||||
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
|
||||
"Relay is controlled by a daemon launch override",
|
||||
);
|
||||
});
|
||||
|
||||
test("unrelated patches do not persist a one-launch relay override", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
...persisted,
|
||||
daemon: { ...persisted.daemon, relay: { enabled: false } },
|
||||
})}\n`,
|
||||
);
|
||||
const store = new DaemonConfigStore(
|
||||
paseoHome,
|
||||
{
|
||||
relay: { enabled: true },
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
},
|
||||
undefined,
|
||||
{ relayEnabledMutable: false },
|
||||
);
|
||||
|
||||
store.patch({ browserTools: { enabled: true } });
|
||||
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("patch persists provider enabled flags into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -27,6 +27,11 @@ export interface DaemonConfigChangeDetails {
|
||||
type ConfigListener = (config: MutableDaemonConfig, details: DaemonConfigChangeDetails) => void;
|
||||
type FieldChangeHandler = (value: unknown) => void;
|
||||
|
||||
interface AppliedFieldChange {
|
||||
handler: FieldChangeHandler;
|
||||
previousValue: unknown;
|
||||
}
|
||||
|
||||
function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
|
||||
return logger?.child({ module: "daemon-config-store" });
|
||||
}
|
||||
@@ -162,11 +167,21 @@ export class DaemonConfigStore {
|
||||
private readonly logger: LoggerLike | undefined;
|
||||
private readonly changeListeners = new Set<ConfigListener>();
|
||||
private readonly fieldChangeHandlers = new Map<string, Set<FieldChangeHandler>>();
|
||||
private readonly relayEnabledMutable: boolean;
|
||||
|
||||
constructor(paseoHome: string, initial: MutableDaemonConfig, logger?: LoggerLike) {
|
||||
constructor(
|
||||
paseoHome: string,
|
||||
initial: MutableDaemonConfig,
|
||||
logger?: LoggerLike,
|
||||
options: { relayEnabledMutable?: boolean } = {},
|
||||
) {
|
||||
this.paseoHome = paseoHome;
|
||||
this.logger = getLogger(logger);
|
||||
this.current = MutableDaemonConfigSchema.parse(initial);
|
||||
this.current = MutableDaemonConfigSchema.parse({
|
||||
...initial,
|
||||
relay: initial.relay ?? { enabled: true },
|
||||
});
|
||||
this.relayEnabledMutable = options.relayEnabledMutable ?? true;
|
||||
}
|
||||
|
||||
public get(): MutableDaemonConfig {
|
||||
@@ -175,6 +190,11 @@ export class DaemonConfigStore {
|
||||
|
||||
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
|
||||
const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial);
|
||||
if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) {
|
||||
throw new Error(
|
||||
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
|
||||
);
|
||||
}
|
||||
const { removeProviders = [], ...configPatch } = parsedPatch;
|
||||
const removedProviders = Array.from(new Set(removeProviders));
|
||||
const merged = deepMerge(this.current, configPatch);
|
||||
@@ -194,24 +214,32 @@ export class DaemonConfigStore {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
// Persist before updating in-memory state so that if persistence fails,
|
||||
// runtime and disk stay consistent.
|
||||
this.persistConfig(next, removedProviders);
|
||||
if (!configChanged) {
|
||||
return this.current;
|
||||
}
|
||||
const persistedBeforePatch = this.persistConfig(next, removedProviders);
|
||||
if (!configChanged) return this.current;
|
||||
|
||||
const previous = this.current;
|
||||
const appliedFieldChanges: AppliedFieldChange[] = [];
|
||||
this.current = next;
|
||||
|
||||
for (const path of changedFieldPaths) {
|
||||
const handlers = this.fieldChangeHandlers.get(path);
|
||||
if (!handlers) {
|
||||
continue;
|
||||
try {
|
||||
for (const path of changedFieldPaths) {
|
||||
const handlers = this.fieldChangeHandlers.get(path);
|
||||
if (!handlers) {
|
||||
continue;
|
||||
}
|
||||
const value = getValueAtPath(next, path);
|
||||
const previousValue = getValueAtPath(previous, path);
|
||||
for (const handler of handlers) {
|
||||
appliedFieldChanges.push({ handler, previousValue });
|
||||
handler(value);
|
||||
}
|
||||
}
|
||||
const value = getValueAtPath(next, path);
|
||||
for (const handler of handlers) {
|
||||
handler(value);
|
||||
} catch (error) {
|
||||
this.current = previous;
|
||||
for (const change of appliedFieldChanges.toReversed()) {
|
||||
change.handler(change.previousValue);
|
||||
}
|
||||
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const changeDetails: DaemonConfigChangeDetails = { removedProviders };
|
||||
@@ -246,14 +274,19 @@ export class DaemonConfigStore {
|
||||
};
|
||||
}
|
||||
|
||||
private persistConfig(config: MutableDaemonConfig, removeProviders: readonly string[]): void {
|
||||
private persistConfig(
|
||||
config: MutableDaemonConfig,
|
||||
removeProviders: readonly string[],
|
||||
): PersistedConfig {
|
||||
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
|
||||
const nextPersisted = mergeMutableConfigIntoPersistedConfig({
|
||||
persisted,
|
||||
mutable: config,
|
||||
removeProviders,
|
||||
persistRelayEnabled: this.relayEnabledMutable,
|
||||
});
|
||||
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
|
||||
return persisted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +294,12 @@ function mergeMutableConfigIntoPersistedConfig(params: {
|
||||
persisted: PersistedConfig;
|
||||
mutable: MutableDaemonConfig;
|
||||
removeProviders: readonly string[];
|
||||
persistRelayEnabled: boolean;
|
||||
}): PersistedConfig {
|
||||
const { persisted, mutable, removeProviders } = params;
|
||||
const { persisted, mutable, removeProviders, persistRelayEnabled } = params;
|
||||
if (!mutable.relay) {
|
||||
throw new Error("Mutable daemon config is missing relay state");
|
||||
}
|
||||
const browserToolsEnabled = readBrowserToolsEnabled(mutable);
|
||||
const metadataGenerationProviders = readMetadataGenerationProviders(mutable);
|
||||
const persistedProviderOverrides = omitProvidersFromOverrides(
|
||||
@@ -300,6 +337,14 @@ function mergeMutableConfigIntoPersistedConfig(params: {
|
||||
...persisted,
|
||||
daemon: {
|
||||
...persisted.daemon,
|
||||
...(persistRelayEnabled
|
||||
? {
|
||||
relay: {
|
||||
...persisted.daemon?.relay,
|
||||
enabled: mutable.relay.enabled,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
mcp: {
|
||||
...persisted.daemon?.mcp,
|
||||
injectIntoAgents: mutable.mcp.injectIntoAgents,
|
||||
|
||||
@@ -84,8 +84,13 @@ function bootstrapFromEnvironment(): BootstrapResult {
|
||||
}
|
||||
|
||||
function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
|
||||
if (process.argv.includes("--relay")) {
|
||||
config.relayEnabled = true;
|
||||
config.relayEnabledMutable = false;
|
||||
}
|
||||
if (process.argv.includes("--no-relay")) {
|
||||
config.relayEnabled = false;
|
||||
config.relayEnabledMutable = false;
|
||||
}
|
||||
if (process.argv.includes("--relay-use-tls")) {
|
||||
config.relayUseTls = true;
|
||||
|
||||
@@ -648,6 +648,16 @@ describe("PersistedConfigSchema voice mode config", () => {
|
||||
});
|
||||
|
||||
describe("loadPersistedConfig", () => {
|
||||
test("materializes relay disabled for a new Paseo home", () => {
|
||||
const home = createTempHome();
|
||||
try {
|
||||
const config = loadPersistedConfig(home);
|
||||
expect(config.daemon?.relay?.enabled).toBe(false);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts the documented config schema marker", () => {
|
||||
const home = createTempHome();
|
||||
const configPath = path.join(home, "config.json");
|
||||
|
||||
@@ -334,7 +334,7 @@ const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({
|
||||
allowedOrigins: ["https://app.paseo.sh"],
|
||||
},
|
||||
relay: {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
app: {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import pino from "pino";
|
||||
import { generateKeyPair } from "@getpaseo/relay";
|
||||
import { createRelayRuntime } from "./relay-runtime.js";
|
||||
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
||||
|
||||
describe("RelayRuntime", () => {
|
||||
test("starts and stops transport as enabled state changes", async () => {
|
||||
const stops: Array<ReturnType<typeof vi.fn>> = [];
|
||||
const starts: string[] = [];
|
||||
const startTransport: typeof startRelayTransport = (options) => {
|
||||
starts.push(options.relayEndpoint);
|
||||
const stop = vi.fn(async () => undefined);
|
||||
stops.push(stop);
|
||||
return { stop } satisfies RelayTransportController;
|
||||
};
|
||||
const runtime = createRelayRuntime({
|
||||
config: {
|
||||
enabled: false,
|
||||
endpoint: "relay.example.test:443",
|
||||
publicEndpoint: "relay.example.test:443",
|
||||
useTls: true,
|
||||
publicUseTls: true,
|
||||
},
|
||||
logger: pino({ level: "silent" }),
|
||||
attachSocket: async () => undefined,
|
||||
serverId: "relay-runtime-test",
|
||||
daemonKeyPair: generateKeyPair(),
|
||||
startTransport,
|
||||
});
|
||||
|
||||
expect(starts).toEqual([]);
|
||||
runtime.setEnabled(true);
|
||||
runtime.setEnabled(true);
|
||||
expect(starts).toEqual(["relay.example.test:443"]);
|
||||
expect(runtime.getConfig().enabled).toBe(true);
|
||||
|
||||
runtime.setEnabled(false);
|
||||
await vi.waitFor(() => expect(stops[0]).toHaveBeenCalledOnce());
|
||||
expect(runtime.getConfig().enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps relay disabled when transport startup fails", () => {
|
||||
const runtime = createRelayRuntime({
|
||||
config: {
|
||||
enabled: false,
|
||||
endpoint: "invalid-endpoint",
|
||||
publicEndpoint: "invalid-endpoint",
|
||||
useTls: false,
|
||||
publicUseTls: false,
|
||||
},
|
||||
logger: pino({ level: "silent" }),
|
||||
attachSocket: async () => undefined,
|
||||
serverId: "relay-runtime-test",
|
||||
daemonKeyPair: generateKeyPair(),
|
||||
startTransport: () => {
|
||||
throw new Error("Invalid relay endpoint");
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => runtime.setEnabled(true)).toThrow("Invalid relay endpoint");
|
||||
expect(runtime.getConfig().enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type pino from "pino";
|
||||
import type { KeyPair } from "@getpaseo/relay/e2ee";
|
||||
import type { ExternalSocketMetadata } from "./websocket-server.js";
|
||||
import {
|
||||
startRelayTransport,
|
||||
type RelaySocketLike,
|
||||
type RelayTransportController,
|
||||
} from "./relay-transport.js";
|
||||
|
||||
export interface RelayRuntimeConfig {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
useTls: boolean;
|
||||
publicUseTls: boolean;
|
||||
}
|
||||
|
||||
interface RelayRuntimeOptions {
|
||||
config: RelayRuntimeConfig;
|
||||
logger: pino.Logger;
|
||||
attachSocket(ws: RelaySocketLike, metadata?: ExternalSocketMetadata): Promise<void>;
|
||||
serverId: string;
|
||||
daemonKeyPair: KeyPair;
|
||||
startTransport?: typeof startRelayTransport;
|
||||
}
|
||||
|
||||
export interface RelayRuntime {
|
||||
getConfig(): RelayRuntimeConfig;
|
||||
setEnabled(enabled: boolean): void;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createRelayRuntime(options: RelayRuntimeOptions): RelayRuntime {
|
||||
const startTransport = options.startTransport ?? startRelayTransport;
|
||||
let config = options.config;
|
||||
let transport: RelayTransportController | null = null;
|
||||
|
||||
function start(): void {
|
||||
if (transport) return;
|
||||
transport = startTransport({
|
||||
logger: options.logger,
|
||||
attachSocket: options.attachSocket,
|
||||
relayEndpoint: config.endpoint,
|
||||
relayUseTls: config.useTls,
|
||||
serverId: options.serverId,
|
||||
daemonKeyPair: options.daemonKeyPair,
|
||||
});
|
||||
}
|
||||
|
||||
function setEnabled(enabled: boolean): void {
|
||||
if (config.enabled === enabled) return;
|
||||
if (enabled) {
|
||||
start();
|
||||
config = { ...config, enabled: true };
|
||||
return;
|
||||
}
|
||||
config = { ...config, enabled: false };
|
||||
const current = transport;
|
||||
transport = null;
|
||||
void current?.stop().catch((error) => {
|
||||
options.logger.warn({ err: error }, "Failed to stop relay transport");
|
||||
});
|
||||
}
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
const current = transport;
|
||||
transport = null;
|
||||
await current?.stop();
|
||||
}
|
||||
|
||||
if (config.enabled) start();
|
||||
|
||||
return {
|
||||
getConfig: () => config,
|
||||
setEnabled,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
|
||||
import type { ExternalSocketMetadata } from "./websocket-server.js";
|
||||
import { createEncryptedRelaySocket } from "./websocket/encrypted-relay-socket.js";
|
||||
|
||||
interface RelayTransportOptions {
|
||||
export interface RelayTransportOptions {
|
||||
logger: pino.Logger;
|
||||
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>;
|
||||
relayEndpoint: string; // "host:port"
|
||||
@@ -25,7 +25,7 @@ export interface RelayTransportController {
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface RelaySocketLike {
|
||||
export interface RelaySocketLike {
|
||||
readyState: number;
|
||||
bufferedAmount?: number;
|
||||
send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void;
|
||||
|
||||
@@ -1425,7 +1425,7 @@ describe("daemon status + pairing RPC", () => {
|
||||
paseoHome: makeHome(),
|
||||
serverId: "srv-test",
|
||||
daemonVersion: "9.9.9",
|
||||
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
|
||||
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
|
||||
agentManager: {
|
||||
listProviderAvailability: vi.fn().mockResolvedValue([
|
||||
{ provider: "claude", available: true },
|
||||
@@ -1464,7 +1464,7 @@ describe("daemon status + pairing RPC", () => {
|
||||
paseoHome: makeHome(),
|
||||
serverId: "srv-test",
|
||||
daemonVersion: "9.9.9",
|
||||
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
|
||||
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
|
||||
agentManager: {
|
||||
listProviderAvailability: vi.fn().mockRejectedValue(new Error("provider listing failed")),
|
||||
},
|
||||
@@ -1500,13 +1500,13 @@ describe("daemon status + pairing RPC", () => {
|
||||
paseoHome: makeHome(),
|
||||
daemonRuntimeConfig: {
|
||||
listen: "127.0.0.1:6767",
|
||||
relay: {
|
||||
getRelayConfig: () => ({
|
||||
enabled: false,
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
publicEndpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
publicUseTls: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("DaemonSession", () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
serverId: "srv-1",
|
||||
daemonVersion: "1.2.3",
|
||||
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
|
||||
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
|
||||
listProviderAvailability: async () => [
|
||||
{ provider: "claude", available: true, error: null },
|
||||
{ provider: "codex", available: false, error: "boom" },
|
||||
@@ -161,7 +161,7 @@ describe("DaemonSession", () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
serverId: "srv-1",
|
||||
daemonVersion: "1.2.3",
|
||||
daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null },
|
||||
daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null },
|
||||
listProviderAvailability: async () => {
|
||||
throw new Error("provider listing failed");
|
||||
},
|
||||
@@ -191,13 +191,13 @@ describe("DaemonSession", () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
daemonRuntimeConfig: {
|
||||
listen: "127.0.0.1:6767",
|
||||
relay: {
|
||||
getRelayConfig: () => ({
|
||||
enabled: false,
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
publicEndpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
publicUseTls: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -219,13 +219,13 @@ describe("DaemonSession", () => {
|
||||
daemonRuntimeConfig: {
|
||||
listen: "127.0.0.1:6767",
|
||||
appBaseUrl: "https://app.example.test",
|
||||
relay: {
|
||||
getRelayConfig: () => ({
|
||||
enabled: true,
|
||||
endpoint: "relay.example.test:443",
|
||||
publicEndpoint: "relay.example.test:443",
|
||||
useTls: true,
|
||||
publicUseTls: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -246,19 +246,53 @@ describe("DaemonSession", () => {
|
||||
expect(typeof message.payload.qr).toBe("string");
|
||||
});
|
||||
|
||||
test("pairing offer reads relay state at request time", async () => {
|
||||
let enabled = false;
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
daemonRuntimeConfig: {
|
||||
listen: "127.0.0.1:6767",
|
||||
appBaseUrl: "https://app.example.test",
|
||||
getRelayConfig: () => ({
|
||||
enabled,
|
||||
endpoint: "relay.example.test:443",
|
||||
publicEndpoint: "relay.example.test:443",
|
||||
useTls: true,
|
||||
publicUseTls: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await subsystem.handleGetPairingOfferRequest({
|
||||
type: "daemon.get_pairing_offer.request",
|
||||
requestId: "disabled",
|
||||
});
|
||||
enabled = true;
|
||||
await subsystem.handleGetPairingOfferRequest({
|
||||
type: "daemon.get_pairing_offer.request",
|
||||
requestId: "enabled",
|
||||
});
|
||||
|
||||
const pairingResponses = emitted.filter(
|
||||
(message) => message.type === "daemon.get_pairing_offer.response",
|
||||
);
|
||||
expect(pairingResponses[0]?.payload.relayEnabled).toBe(false);
|
||||
expect(pairingResponses[1]?.payload.relayEnabled).toBe(true);
|
||||
expect(pairingResponses[1]?.payload.url).toContain("#offer=");
|
||||
});
|
||||
|
||||
test("diagnostics includes a log tail and redacts connection secrets", async () => {
|
||||
const { subsystem, emitted, paseoHome } = makeSubsystem({
|
||||
serverId: "srv-1",
|
||||
daemonVersion: "1.2.3",
|
||||
daemonRuntimeConfig: {
|
||||
listen: "127.0.0.1:6767",
|
||||
relay: {
|
||||
getRelayConfig: () => ({
|
||||
enabled: true,
|
||||
endpoint: "relay.secret.test:443",
|
||||
publicEndpoint: "relay.secret.test:443",
|
||||
useTls: true,
|
||||
publicUseTls: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
writeFileSync(
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface DaemonRuntimeConfig {
|
||||
worktreesRoot?: string;
|
||||
appBaseUrl?: string;
|
||||
desktopManaged?: boolean;
|
||||
relay: {
|
||||
getRelayConfig(): {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
publicEndpoint: string;
|
||||
@@ -169,7 +169,7 @@ export class DaemonSession {
|
||||
nodePath: process.execPath,
|
||||
startedAt: pidInfo?.startedAt ?? null,
|
||||
listen: this.daemonRuntimeConfig?.listen ?? null,
|
||||
relay: this.daemonRuntimeConfig?.relay ?? null,
|
||||
relay: this.daemonRuntimeConfig?.getRelayConfig() ?? null,
|
||||
providers,
|
||||
},
|
||||
});
|
||||
@@ -196,10 +196,10 @@ export class DaemonSession {
|
||||
msg: Extract<SessionInboundMessage, { type: "daemon.get_pairing_offer.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const relay = this.daemonRuntimeConfig?.relay;
|
||||
const relay = this.daemonRuntimeConfig?.getRelayConfig();
|
||||
const pairing = await generateLocalPairingOffer({
|
||||
paseoHome: this.paseoHome,
|
||||
relayEnabled: relay?.enabled ?? true,
|
||||
relayEnabled: relay?.enabled ?? false,
|
||||
relayEndpoint: relay?.endpoint,
|
||||
relayPublicEndpoint: relay?.publicEndpoint,
|
||||
relayUseTls: relay?.useTls,
|
||||
|
||||
@@ -133,7 +133,7 @@ function collectProcessEntries(options: DaemonDiagnosticsOptions): DiagnosticEnt
|
||||
}
|
||||
|
||||
function collectRuntimeConfigEntries(options: DaemonDiagnosticsOptions): DiagnosticEntry[] {
|
||||
const relay = options.daemonRuntimeConfig?.relay ?? null;
|
||||
const relay = options.daemonRuntimeConfig?.getRelayConfig() ?? null;
|
||||
return [
|
||||
{ label: "Listen", value: formatListenKind(options.daemonRuntimeConfig?.listen ?? null) },
|
||||
{ label: "Relay enabled", value: relay ? String(relay.enabled) : "false" },
|
||||
@@ -536,8 +536,8 @@ export function redactDiagnostic(
|
||||
let redacted = value;
|
||||
const sensitiveValues = [
|
||||
options?.daemonRuntimeConfig?.listen,
|
||||
options?.daemonRuntimeConfig?.relay?.endpoint,
|
||||
options?.daemonRuntimeConfig?.relay?.publicEndpoint,
|
||||
options?.daemonRuntimeConfig?.getRelayConfig()?.endpoint,
|
||||
options?.daemonRuntimeConfig?.getRelayConfig()?.publicEndpoint,
|
||||
].filter((item): item is string => Boolean(item));
|
||||
|
||||
for (const sensitive of sensitiveValues) {
|
||||
|
||||
@@ -12,6 +12,8 @@ async function main(): Promise<void> {
|
||||
corsAllowedOrigins: [`http://localhost:${metroPort}`],
|
||||
daemonVersion: "0.0.0",
|
||||
desktopManaged: process.env.E2E_DESKTOP_MANAGED === "1",
|
||||
daemonStatusRpcCapability: process.env.E2E_DAEMON_STATUS_RPC_CAPABILITY !== "0",
|
||||
relayConfigCapability: process.env.E2E_RELAY_CONFIG_CAPABILITY !== "0",
|
||||
});
|
||||
const serverId = (await readFile(path.join(daemon.paseoHome, "server-id"), "utf8")).trim();
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ interface TestPaseoDaemonOptions {
|
||||
relayEndpoint?: string;
|
||||
relayUseTls?: boolean;
|
||||
relayPublicUseTls?: boolean;
|
||||
daemonStatusRpcCapability?: boolean;
|
||||
relayConfigCapability?: boolean;
|
||||
agentClients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||
providerOverrides?: PaseoDaemonConfig["providerOverrides"];
|
||||
paseoHomeRoot?: string;
|
||||
@@ -92,7 +94,12 @@ export async function createTestPaseoDaemon(
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const { config, paseoHomeRoot, paseoHome, staticDir } = await prepareTestDaemonConfig(options);
|
||||
const logger = options.logger ?? pino({ level: "silent" });
|
||||
const daemon = await createPaseoDaemon(config, logger);
|
||||
const daemon = await createPaseoDaemon(config, logger, {
|
||||
serverFeatureOverrides: {
|
||||
daemonStatusRpc: options.daemonStatusRpcCapability,
|
||||
relayConfig: options.relayConfigCapability,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS);
|
||||
const listenTarget = daemon.getListenTarget();
|
||||
|
||||
@@ -131,6 +131,8 @@ interface WebSocketConnectionIdentity {
|
||||
interface WebSocketServerConfig {
|
||||
allowedOrigins: Set<string>;
|
||||
hostnames?: HostnamesConfig;
|
||||
daemonStatusRpc?: boolean;
|
||||
relayConfig?: boolean;
|
||||
}
|
||||
|
||||
type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
|
||||
@@ -554,6 +556,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly hubRelationships: HubRelationshipManagement | null;
|
||||
private readonly browserToolsRegistrations = new Map<string, BrowserToolsRegistration>();
|
||||
private acceptingConnections = true;
|
||||
private readonly advertiseDaemonStatusRpc: boolean;
|
||||
private readonly advertiseRelayConfig: boolean;
|
||||
|
||||
constructor(
|
||||
server: HTTPServer,
|
||||
@@ -601,6 +605,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
hubRelationships?: HubRelationshipManagement | null,
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.advertiseDaemonStatusRpc = wsConfig.daemonStatusRpc !== false;
|
||||
this.advertiseRelayConfig = wsConfig.relayConfig !== false;
|
||||
this.serverId = serverId;
|
||||
if (typeof daemonVersion !== "string" || daemonVersion.trim().length === 0) {
|
||||
throw new MissingDaemonVersionError();
|
||||
@@ -1520,7 +1526,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
|
||||
forgeSearch: true,
|
||||
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||
daemonStatusRpc: true,
|
||||
...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}),
|
||||
// COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31.
|
||||
...(this.advertiseRelayConfig ? { relayConfig: true } : {}),
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
"terminal-restore-modes": true,
|
||||
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
|
||||
|
||||
Reference in New Issue
Block a user