* Add opt-in browser tools for desktop tabs Adds the daemon opt-in, desktop tab routing, MCP tools, and real browser automation surfaces for Paseo desktop browser tabs. * Fix browser tools CI expectations * Address browser tools review findings * Restrict browser file automation paths * Fix browser upload test on Windows * Harden browser navigation inputs * Make browser tools create usable tabs * Update browser MCP empty-state test * Fail browser tab creation when registration times out * Fix browser screenshots for agents * Hide disabled browser tools from agents * Address browser tools architecture review * Replace browser tools review tests * Wrap browser tab registration errors * Mock Expo Router in app unit tests * Handle invalid browser automation requests * Return browser failure on desktop disconnect * Update browser disconnect websocket test * Relax browser timeout polling test * Handle invalid browser responses * Return browser failure when send fails * Remove local diagnostics and fixture paths * Fix dev service home fallback * Use worktree home for dev services * Use managed daemon in desktop dev * fix(browser): keep agent tabs addressable Track agent-active browser targets separately from human-focused tabs and keep resident webviews alive for automation. Browser tool visibility now comes from registration while the broker reports disabled execution. * refactor(browser): register tools through catalog Move browser tool registration onto the shared Paseo tool catalog so the MCP server remains only the transport adapter. * fix(settings): translate browser tools host error
251 lines
8.6 KiB
TypeScript
251 lines
8.6 KiB
TypeScript
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
|
import type { HostConnection } from "@/types/host-connection";
|
|
import { getOrCreateClientId } from "./client-id";
|
|
import { resolveAppVersion } from "./app-version";
|
|
import {
|
|
buildDaemonWebSocketUrl,
|
|
buildRelayWebSocketUrl,
|
|
shouldUseTlsForDefaultHostedRelay,
|
|
} from "./daemon-endpoints";
|
|
import {
|
|
buildLocalDaemonTransportUrl,
|
|
createDesktopLocalDaemonTransportFactory,
|
|
} from "@/desktop/daemon/desktop-daemon-transport";
|
|
|
|
export interface DaemonProbeClient {
|
|
readonly lastError: string | null;
|
|
connect(): Promise<void>;
|
|
close(): Promise<void>;
|
|
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
|
|
}
|
|
|
|
interface LocalTransportUrlInput {
|
|
transportType: "socket" | "pipe";
|
|
transportPath: string;
|
|
}
|
|
|
|
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
|
|
getClientId(): Promise<string>;
|
|
resolveAppVersion(): string | null;
|
|
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
|
|
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
|
|
createClient(config: DaemonClientConfig): TClient;
|
|
}
|
|
|
|
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
|
|
getClientId: getOrCreateClientId,
|
|
resolveAppVersion,
|
|
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
|
|
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
|
|
createClient: (config) => new DaemonClient(config),
|
|
};
|
|
|
|
function normalizeNonEmptyString(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function pickBestReason(reason: string | null, lastError: string | null): string {
|
|
const genericReason =
|
|
reason &&
|
|
(reason.toLowerCase() === "transport error" || reason.toLowerCase() === "transport closed");
|
|
const genericLastError =
|
|
lastError &&
|
|
(lastError.toLowerCase() === "transport error" ||
|
|
lastError.toLowerCase() === "transport closed" ||
|
|
lastError.toLowerCase() === "unable to connect");
|
|
|
|
if (genericReason && lastError && !genericLastError) {
|
|
return lastError;
|
|
}
|
|
if (reason) return reason;
|
|
if (lastError) return lastError;
|
|
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;
|
|
|
|
constructor(message: string, details: { reason: string | null; lastError: string | null }) {
|
|
super(message);
|
|
this.name = "DaemonConnectionTestError";
|
|
this.reason = details.reason;
|
|
this.lastError = details.lastError;
|
|
}
|
|
}
|
|
|
|
export async function buildClientConfig(
|
|
connection: HostConnection,
|
|
serverId?: string,
|
|
options?: { capabilities?: DaemonClientConfig["capabilities"] },
|
|
deps: Pick<
|
|
DaemonConnectionDependencies<DaemonProbeClient>,
|
|
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
|
|
> = defaultDaemonConnectionDependencies,
|
|
): Promise<DaemonClientConfig> {
|
|
const clientId = await deps.getClientId();
|
|
const localTransportFactory = deps.createLocalTransportFactory();
|
|
const base = {
|
|
clientId,
|
|
clientType: "mobile" as const,
|
|
appVersion: deps.resolveAppVersion() ?? undefined,
|
|
suppressSendErrors: true,
|
|
reconnect: { enabled: false },
|
|
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
|
|
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
|
localTransportFactory
|
|
? { transportFactory: localTransportFactory }
|
|
: {}),
|
|
};
|
|
|
|
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
|
return {
|
|
...base,
|
|
url: deps.buildLocalTransportUrl({
|
|
transportType: connection.type === "directSocket" ? "socket" : "pipe",
|
|
transportPath: connection.path,
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (connection.type === "directTcp") {
|
|
return {
|
|
...base,
|
|
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
|
|
...(connection.password ? { password: connection.password } : {}),
|
|
};
|
|
}
|
|
|
|
if (!serverId) {
|
|
throw new Error("serverId is required to probe a relay connection");
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
url: buildRelayWebSocketUrl({
|
|
endpoint: connection.relayEndpoint,
|
|
useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
|
|
serverId,
|
|
}),
|
|
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
|
|
};
|
|
}
|
|
|
|
export function connectAndProbe(
|
|
config: DaemonClientConfig,
|
|
timeoutMs: number,
|
|
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
|
|
export function connectAndProbe<TClient extends DaemonProbeClient>(
|
|
config: DaemonClientConfig,
|
|
timeoutMs: number,
|
|
deps: Pick<DaemonConnectionDependencies<TClient>, "createClient">,
|
|
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
|
|
export function connectAndProbe(
|
|
config: DaemonClientConfig,
|
|
timeoutMs: number,
|
|
deps: Pick<
|
|
DaemonConnectionDependencies<DaemonProbeClient>,
|
|
"createClient"
|
|
> = defaultDaemonConnectionDependencies,
|
|
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
|
|
const client = deps.createClient(config);
|
|
|
|
return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>(
|
|
(resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
void client.close().catch(() => undefined);
|
|
reject(
|
|
new DaemonConnectionTestError("Connection timed out", {
|
|
reason: "Connection timed out",
|
|
lastError: client.lastError ?? null,
|
|
}),
|
|
);
|
|
}, timeoutMs);
|
|
|
|
void client
|
|
.connect()
|
|
.then(() => {
|
|
clearTimeout(timer);
|
|
const serverInfo = client.getLastServerInfoMessage();
|
|
if (!serverInfo) {
|
|
void client.close().catch(() => undefined);
|
|
reject(
|
|
new DaemonConnectionTestError("Missing server info message", {
|
|
reason: "Missing server info message",
|
|
lastError: client.lastError ?? null,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
resolve({
|
|
client,
|
|
serverId: serverInfo.serverId,
|
|
hostname: serverInfo.hostname,
|
|
});
|
|
return;
|
|
})
|
|
.catch((error) => {
|
|
clearTimeout(timer);
|
|
const reason = normalizeNonEmptyString(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
const lastError = normalizeNonEmptyString(client.lastError);
|
|
const message = isIncorrectPasswordFailure({ config, reason, lastError })
|
|
? "Incorrect password"
|
|
: pickBestReason(reason, lastError);
|
|
void client.close().catch(() => undefined);
|
|
reject(new DaemonConnectionTestError(message, { reason, lastError }));
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
interface ProbeOptions {
|
|
serverId?: string;
|
|
timeoutMs?: number;
|
|
capabilities?: DaemonClientConfig["capabilities"];
|
|
}
|
|
|
|
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
|
|
if (options?.timeoutMs) return options.timeoutMs;
|
|
return connection.type === "relay" ? 10_000 : 6_000;
|
|
}
|
|
|
|
export function connectToDaemon(
|
|
connection: HostConnection,
|
|
options?: ProbeOptions,
|
|
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
|
|
export function connectToDaemon<TClient extends DaemonProbeClient>(
|
|
connection: HostConnection,
|
|
options: ProbeOptions | undefined,
|
|
deps: DaemonConnectionDependencies<TClient>,
|
|
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
|
|
export async function connectToDaemon(
|
|
connection: HostConnection,
|
|
options?: ProbeOptions,
|
|
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
|
|
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
|
|
const config = await buildClientConfig(connection, options?.serverId, options, deps);
|
|
return connectAndProbe(config, resolveTimeout(connection, options), deps);
|
|
}
|