mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Make password-protected daemons work across local and remote connections (#5393)
* feat(auth): authenticate daemon clients through hello Give local CLI and desktop clients a per-run credential while remote clients send the host password inside the protocol. Keep relay passwordless admission for this mobile release so existing saved hosts keep working until the follow-up enforcement release. Move password recovery for saved hosts into host settings and keep pairing credentials in the add flow. Include isolated web QA evidence for both flows. * fix(auth): preserve Hub bootstrap and local credential expectations Hub-owned sockets already carry admission; keep their hello handling synchronous through session bootstrap while resolving credentials asynchronously for ordinary sockets. Keep the resolver out of lightweight configuration imports. Update CLI expectations for same-home credentials, let a rejected connection yield to another usable connection, and clear pairing secrets when add-flow targets change. * fix(pairing): retain passwords across edits to the same target Track the last valid pairing host while a URI is edited. This preserves an entered password for whitespace and partial edits but clears it when a different host becomes the target. * test(auth): align local credential checks across platforms Expect the CLI to authenticate through the credential in its selected home, and verify POSIX file modes only where Node exposes them. * feat(app): move host password into a settings row with a modal The host page rendered an always-visible password form above Name on every host. Replace it with a Password row under a Security section below Appearance; tapping it opens a modal with Save and, when a password is saved, Clear. The connection-error callout keeps its guidance and gains a Set password button that opens the same modal. Drop the unused pairing.hostPassword body/reopen/saveError strings. * chore: remove committed QA screenshots QA evidence stays out of the repository; the PR body describes it in words. * feat(app): keep the host password out of the host page The saved password is connection data, entered only when a host is added. A Password setting on the host page reads as the daemon's own password, so the row, modal, and callout button are gone. A rejected host shows the daemon's reason with guidance to remove the host and add it again with the password. useHostMutations no longer exposes setHostPassword; relay imports still set it inside the runtime. * feat(app): show host connection errors in an error alert HostConnectionError renders the existing Alert with the connection error as its title. Auth rejections add the re-add guidance as the description; other errors show the title only. The Overview and Connections pages share it. * style(app): soften alert borders to half-opacity accent * feat(app): size alerts on the button scale Alert takes a size prop (xs/sm/md/lg, default md) whose geometry lives next to the button's in control-geometry: one font size for title and description, the button icon size centered on the first text line, button padding and radius, and a one-line alert as tall as the button of the same size. Consumers pick sm among settings rows, cards, panels, and modals, and md when the alert is the whole page or sheet state. * style(app): roomier, rounder alerts with a tight title block Alert sm padding returns to the original 12/16 and every size is at least borderRadius.xl. Title and description sit a small gap apart with no line height override. The icon slot carries a zero-width text strut in the alert's font so the icon centers on the first line for titled, title-only, and description-only alerts. * refactor(app): align the alert icon with a centered lead row Drop the zero-width strut. The icon and the alert's first text (title, else description) share one row centered on each other; the description and actions sit below, indented by the icon slot width plus gap so they start at the lead text's left edge. * refactor(app): drop the unused Alert icon override
This commit is contained in:
+6
-2
@@ -42,11 +42,15 @@ The daemon requires a valid cryptographic handshake before processing any comman
|
|||||||
|
|
||||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||||
|
|
||||||
|
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
|
||||||
|
|
||||||
## Local daemon trust boundary
|
## Local daemon trust boundary
|
||||||
|
|
||||||
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
|
||||||
|
|
||||||
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
|
The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens.
|
||||||
|
|
||||||
|
The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
|
||||||
|
|
||||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ $PASEO_HOME/
|
|||||||
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
||||||
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
||||||
├── paseo.pid # Daemon PID lock file
|
├── paseo.pid # Daemon PID lock file
|
||||||
|
├── local-credential # Per-run local client credential (mode 0600)
|
||||||
├── daemon.log # Default log file (path configurable)
|
├── daemon.log # Default log file (path configurable)
|
||||||
├── agents/
|
├── agents/
|
||||||
│ └── {sanitized-cwd}/
|
│ └── {sanitized-cwd}/
|
||||||
@@ -557,6 +558,7 @@ These small files are not validated as full Zod schemas but are persisted under
|
|||||||
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
||||||
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
||||||
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
||||||
|
| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. |
|
||||||
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ Two contracts follow from it.
|
|||||||
|
|
||||||
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
|
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
|
||||||
|
|
||||||
|
`hello` carries an optional credential. The daemon accepts a client protocol version from its minimum through newer client versions, selects the lower of client and daemon maximum, and reports it as optional `server_info.protocolVersion`. A `hello.rejected` frame precedes an auth close only when the client sent `hello.auth` or advertised `hello_rejection`; older clients receive the existing WebSocket close code and reason. This avoids sending them a new top-level message their validator does not recognize.
|
||||||
|
|
||||||
- New fields are `.optional()` with a sensible default.
|
- New fields are `.optional()` with a sensible default.
|
||||||
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
|
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
|
||||||
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
|
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { expect, test } from "../support/fixtures";
|
|||||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||||
import { TEST_HOST_LABEL } from "../support/helpers/daemon-registry";
|
import { TEST_HOST_LABEL } from "../support/helpers/daemon-registry";
|
||||||
|
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
import {
|
import {
|
||||||
expectSettingsHeader,
|
expectSettingsHeader,
|
||||||
@@ -18,6 +19,12 @@ import {
|
|||||||
expectRetiredSidebarSectionsAbsent,
|
expectRetiredSidebarSectionsAbsent,
|
||||||
expectHostPageVisible,
|
expectHostPageVisible,
|
||||||
seedSavedSettingsHosts,
|
seedSavedSettingsHosts,
|
||||||
|
selectSettingsHost,
|
||||||
|
expectHostRejectedWithReAddGuidance,
|
||||||
|
expectNoHostPasswordControls,
|
||||||
|
removeHostFromHostPage,
|
||||||
|
addDirectHostFromSettings,
|
||||||
|
expectHostOnlineWithoutError,
|
||||||
} from "../support/helpers/settings";
|
} from "../support/helpers/settings";
|
||||||
|
|
||||||
test.describe("Settings host page", () => {
|
test.describe("Settings host page", () => {
|
||||||
@@ -89,6 +96,47 @@ test.describe("Settings host page", () => {
|
|||||||
await expect(updateButton).toBeEnabled();
|
await expect(updateButton).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a host rejected for its password reconnects after being removed and added again with it", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const password = "e2e host password";
|
||||||
|
const daemon = await startIsolatedHostDaemon("srv_e2e_password_host", {
|
||||||
|
environment: { ...process.env, PASEO_PASSWORD: password },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await seedSavedSettingsHosts(page, [
|
||||||
|
{
|
||||||
|
serverId: getServerId(),
|
||||||
|
label: TEST_HOST_LABEL,
|
||||||
|
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: daemon.serverId,
|
||||||
|
label: "Password host",
|
||||||
|
endpoint: `127.0.0.1:${daemon.port}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await page.reload();
|
||||||
|
await openSettings(page);
|
||||||
|
await selectSettingsHost(page, daemon.serverId);
|
||||||
|
await openHostSection(page, daemon.serverId, "host");
|
||||||
|
|
||||||
|
await test.step("the host page shows the reason and the re-add guidance, with no password UI", async () => {
|
||||||
|
await expectHostRejectedWithReAddGuidance(page, "Password required");
|
||||||
|
await expectNoHostPasswordControls(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("removing the host and adding it again with the password brings it online", async () => {
|
||||||
|
await removeHostFromHostPage(page, daemon.serverId);
|
||||||
|
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: daemon.port, password });
|
||||||
|
await openHostSection(page, daemon.serverId, "host");
|
||||||
|
await expectHostOnlineWithoutError(page);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await daemon.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -91,12 +91,15 @@ export async function selectHostConnectionType(
|
|||||||
|
|
||||||
export async function addDirectHostFromSettings(
|
export async function addDirectHostFromSettings(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { host: string; port: number },
|
input: { host: string; port: number; password?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await openAddHostFlow(page);
|
await openAddHostFlow(page);
|
||||||
await selectHostConnectionType(page, "direct");
|
await selectHostConnectionType(page, "direct");
|
||||||
await page.getByTestId("direct-host-input").fill(input.host);
|
await page.getByTestId("direct-host-input").fill(input.host);
|
||||||
await page.getByTestId("direct-port-input").fill(String(input.port));
|
await page.getByTestId("direct-port-input").fill(String(input.port));
|
||||||
|
if (input.password) {
|
||||||
|
await page.getByTestId("direct-password-input").fill(input.password);
|
||||||
|
}
|
||||||
await page.getByTestId("direct-host-submit").click();
|
await page.getByTestId("direct-host-submit").click();
|
||||||
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
|
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
@@ -418,3 +421,31 @@ export async function expectLocalHostEntryFirst(page: Page, _serverId: string):
|
|||||||
await expect(picker).toBeVisible();
|
await expect(picker).toBeVisible();
|
||||||
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
|
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectHostRejectedWithReAddGuidance(
|
||||||
|
page: Page,
|
||||||
|
reason: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const error = page.getByTestId("host-connection-error");
|
||||||
|
await expect(error).toContainText(reason);
|
||||||
|
await expect(error).toContainText(
|
||||||
|
"Remove this host and add it again with the password this daemon asks for.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectNoHostPasswordControls(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("Password", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: /password/i })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeHostFromHostPage(page: Page, serverId: string): Promise<void> {
|
||||||
|
await page.getByTestId("host-page-remove-host-button").click();
|
||||||
|
await page.getByTestId("remove-host-confirm").click();
|
||||||
|
await expect(page).not.toHaveURL(new RegExp(`/settings/hosts/${serverId}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
|
||||||
|
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ import {
|
|||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
hasConfiguredLocalDaemonOverride,
|
hasConfiguredLocalDaemonOverride,
|
||||||
useHostRegistryLoaded,
|
useHostRegistryLoaded,
|
||||||
useHostMutations,
|
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
useHostRuntimeIsConnected,
|
useHostRuntimeIsConnected,
|
||||||
useHosts,
|
useHosts,
|
||||||
@@ -666,13 +665,11 @@ function MobileGestureWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppearanceProvider>
|
<AppearanceProvider>
|
||||||
<VoiceProvider>
|
<VoiceProvider>
|
||||||
<DesktopWindowControlsSync />
|
<DesktopWindowControlsSync />
|
||||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
<OfferLinkListener />
|
||||||
<HostSessionManager />
|
<HostSessionManager />
|
||||||
<FaviconStatusSync />
|
<FaviconStatusSync />
|
||||||
{children}
|
{children}
|
||||||
@@ -699,45 +696,36 @@ function DesktopWindowControlsSync() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OfferLinkListener({
|
function OfferLinkListener() {
|
||||||
upsertDaemonFromOfferUrl,
|
|
||||||
}: {
|
|
||||||
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const handleUrl = (url: string | null) => {
|
const handleUrl = async (url: string | null) => {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
if (!url.includes("#offer=")) return;
|
if (!url.includes("#offer=") && !url.includes("#connect=") && !url.startsWith("relay://"))
|
||||||
void upsertDaemonFromOfferUrl(url)
|
return;
|
||||||
.then((profile) => {
|
try {
|
||||||
if (cancelled) return;
|
const result = await getHostRuntimeStore().importConnectionLink(url, "openProject");
|
||||||
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
|
if (!cancelled && result.status === "connected") router.replace(buildOpenProjectRoute());
|
||||||
if (typeof serverId !== "string" || !serverId) return;
|
} catch (error) {
|
||||||
router.replace(buildOpenProjectRoute());
|
console.warn("[OfferLinkListener] Pairing link failed", error);
|
||||||
return;
|
}
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
console.warn("[Linking] Failed to import pairing offer", error);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
void Linking.getInitialURL()
|
void Linking.getInitialURL()
|
||||||
.then(handleUrl)
|
.then((url) => handleUrl(url))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
const subscription = Linking.addEventListener("url", (event) => {
|
const subscription = Linking.addEventListener("url", (event) => {
|
||||||
handleUrl(event.url);
|
void handleUrl(event.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, [router, upsertDaemonFromOfferUrl]);
|
}, [router]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||||
import { useHostMutations } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
|
||||||
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
|
||||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
|
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -114,7 +112,8 @@ function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
|
|||||||
const raw = typeof result.data === "string" ? result.data.trim() : "";
|
const raw = typeof result.data === "string" ? result.data.trim() : "";
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
if (raw.includes("#offer=")) return raw;
|
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://"))
|
||||||
|
return raw;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -128,11 +127,11 @@ export default function PairScanScreen() {
|
|||||||
source?: string;
|
source?: string;
|
||||||
}>();
|
}>();
|
||||||
const source = typeof params.source === "string" ? params.source : "settings";
|
const source = typeof params.source === "string" ? params.source : "settings";
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
|
||||||
|
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [isPairing, setIsPairing] = useState(false);
|
const [isPairing, setIsPairing] = useState(false);
|
||||||
const lastScannedRef = useRef<string | null>(null);
|
const [scanError, setScanError] = useState<string | null>(null);
|
||||||
|
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
const navigateToPairedHost = useCallback(
|
const navigateToPairedHost = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
@@ -160,48 +159,34 @@ export default function PairScanScreen() {
|
|||||||
}, [permission, requestPermission]);
|
}, [permission, requestPermission]);
|
||||||
|
|
||||||
const handleScan = useCallback(
|
const handleScan = useCallback(
|
||||||
async (result: BarcodeScanningResult) => {
|
(result: BarcodeScanningResult) => {
|
||||||
if (isPairing) return;
|
if (isPairing) return;
|
||||||
const offerUrl = extractOfferUrlFromScan(result);
|
const offerUrl = extractOfferUrlFromScan(result);
|
||||||
if (!offerUrl) return;
|
if (!offerUrl) return;
|
||||||
|
|
||||||
if (lastScannedRef.current === offerUrl) return;
|
const store = getHostRuntimeStore();
|
||||||
lastScannedRef.current = offerUrl;
|
if (passwordOfferUrl) return;
|
||||||
|
setIsPairing(true);
|
||||||
try {
|
setScanError(null);
|
||||||
setIsPairing(true);
|
void store
|
||||||
const idx = offerUrl.indexOf("#offer=");
|
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
|
||||||
const encoded = offerUrl.slice(idx + "#offer=".length).trim();
|
.then((outcome) => {
|
||||||
const offerPayload = decodeOfferFragmentPayload(encoded);
|
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
|
||||||
const offer = ConnectionOfferSchema.parse(offerPayload);
|
else setPasswordOfferUrl(offerUrl);
|
||||||
|
return outcome;
|
||||||
const { client, hostname } = await connectToDaemon(
|
})
|
||||||
{
|
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
|
||||||
id: "probe",
|
.finally(() => setIsPairing(false));
|
||||||
type: "relay",
|
|
||||||
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
|
|
||||||
useTls: offer.relay.useTls,
|
|
||||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
|
||||||
},
|
|
||||||
{ serverId: offer.serverId },
|
|
||||||
);
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
|
|
||||||
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
|
||||||
|
|
||||||
navigateToPairedHost(profile.serverId);
|
|
||||||
} catch (error) {
|
|
||||||
lastScannedRef.current = null;
|
|
||||||
const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
|
|
||||||
Alert.alert(t("pairing.scan.errorTitle"), message);
|
|
||||||
} finally {
|
|
||||||
setIsPairing(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
|
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||||
|
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
|
||||||
|
const savePasswordPairing = useCallback(
|
||||||
|
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
||||||
|
[navigateToPairedHost],
|
||||||
|
);
|
||||||
const handleRequestPermission = useCallback(() => {
|
const handleRequestPermission = useCallback(() => {
|
||||||
void requestPermission();
|
void requestPermission();
|
||||||
}, [requestPermission]);
|
}, [requestPermission]);
|
||||||
@@ -263,10 +248,18 @@ export default function PairScanScreen() {
|
|||||||
<View style={[styles.corner, styles.cornerBR]} />
|
<View style={[styles.corner, styles.cornerBR]} />
|
||||||
</View>
|
</View>
|
||||||
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
||||||
|
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
<PairLinkModal
|
||||||
|
visible={passwordOfferUrl !== null}
|
||||||
|
initialUrl={passwordOfferUrl ?? undefined}
|
||||||
|
initialPasswordRequired
|
||||||
|
onClose={closePasswordModal}
|
||||||
|
onSaved={savePasswordPairing}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogB
|
|||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
<Alert
|
<Alert
|
||||||
|
size="md"
|
||||||
variant="error"
|
variant="error"
|
||||||
title={t("changelog.error.title")}
|
title={t("changelog.error.title")}
|
||||||
description={t("changelog.error.description")}
|
description={t("changelog.error.description")}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Pressable, Text, View } from "react-native";
|
import { Alert, Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -11,9 +11,13 @@ import {
|
|||||||
serializeConnectionUri,
|
serializeConnectionUri,
|
||||||
serializeConnectionUriForStorage,
|
serializeConnectionUriForStorage,
|
||||||
} from "@/utils/daemon-endpoints";
|
} from "@/utils/daemon-endpoints";
|
||||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
import {
|
||||||
|
DaemonConnectionTestError,
|
||||||
|
getConnectionAuthFailureReason,
|
||||||
|
} from "@/utils/test-daemon-connection";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||||
|
|
||||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||||
|
|
||||||
@@ -250,8 +254,8 @@ function buildConnectionFailureCopy(input: {
|
|||||||
const rawLower = raw?.toLowerCase() ?? "";
|
const rawLower = raw?.toLowerCase() ?? "";
|
||||||
let detail: string | null = null;
|
let detail: string | null = null;
|
||||||
|
|
||||||
if (raw === "Incorrect password" || raw === "Password required") {
|
if (getConnectionAuthFailureReason(error)) {
|
||||||
detail = raw;
|
detail = error instanceof Error ? error.message : raw;
|
||||||
} else if (rawLower.includes("timed out")) {
|
} else if (rawLower.includes("timed out")) {
|
||||||
detail = labels.timedOut;
|
detail = labels.timedOut;
|
||||||
} else if (
|
} else if (
|
||||||
@@ -293,7 +297,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { probeAndUpsertDirectConnection } = useHostMutations();
|
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
|
||||||
|
useHostMutations();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -306,6 +311,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||||
const [advancedUri, setAdvancedUri] = useState("");
|
const [advancedUri, setAdvancedUri] = useState("");
|
||||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||||
|
const advancedTarget = useRef(new PairingTargetTracker("", true));
|
||||||
|
|
||||||
const clearInput = useCallback(() => {
|
const clearInput = useCallback(() => {
|
||||||
setHost("");
|
setHost("");
|
||||||
@@ -315,6 +321,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
setIsPasswordVisible(false);
|
setIsPasswordVisible(false);
|
||||||
setIsAdvancedOpen(false);
|
setIsAdvancedOpen(false);
|
||||||
setAdvancedUri("");
|
setAdvancedUri("");
|
||||||
|
advancedTarget.current = new PairingTargetTracker("", true);
|
||||||
bumpInputResetKey();
|
bumpInputResetKey();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -365,9 +372,45 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
(onCancel ?? onClose)();
|
(onCancel ?? onClose)();
|
||||||
}, [isSaving, clearInput, onCancel, onClose]);
|
}, [isSaving, clearInput, onCancel, onClose]);
|
||||||
|
|
||||||
|
const handleSaveRelay = useCallback(
|
||||||
|
async (relayUri: string) => {
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
||||||
|
relayUri,
|
||||||
|
password || undefined,
|
||||||
|
);
|
||||||
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||||
|
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||||
|
handleClose();
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(
|
||||||
|
error instanceof Error ? error.message : directConnectionLabels.invalidConnection,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
daemons,
|
||||||
|
directConnectionLabels.invalidConnection,
|
||||||
|
handleClose,
|
||||||
|
onSaved,
|
||||||
|
password,
|
||||||
|
probeAndUpsertConnectionFromOfferUrl,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
|
|
||||||
|
const relayUri = isAdvancedOpen ? advancedUri.trim() : "";
|
||||||
|
if (relayUri.startsWith("relay://") || relayUri.includes("#connect=")) {
|
||||||
|
await handleSaveRelay(relayUri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let connection: PreparedDirectConnection;
|
let connection: PreparedDirectConnection;
|
||||||
try {
|
try {
|
||||||
connection = prepareDirectConnection(
|
connection = prepareDirectConnection(
|
||||||
@@ -422,10 +465,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
advancedUri,
|
||||||
daemons,
|
daemons,
|
||||||
directConnectionLabels,
|
directConnectionLabels,
|
||||||
handleClose,
|
handleClose,
|
||||||
host,
|
host,
|
||||||
|
isAdvancedOpen,
|
||||||
isMobile,
|
isMobile,
|
||||||
isSaving,
|
isSaving,
|
||||||
onSaved,
|
onSaved,
|
||||||
@@ -433,6 +478,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
port,
|
port,
|
||||||
probeAndUpsertDirectConnection,
|
probeAndUpsertDirectConnection,
|
||||||
t,
|
t,
|
||||||
|
handleSaveRelay,
|
||||||
useTls,
|
useTls,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -444,6 +490,15 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
void handleSave();
|
void handleSave();
|
||||||
}, [handleSave]);
|
}, [handleSave]);
|
||||||
|
|
||||||
|
const handleChangeAdvancedUri = useCallback((next: string) => {
|
||||||
|
if (advancedTarget.current.changeUrl(next)) {
|
||||||
|
setPassword("");
|
||||||
|
bumpInputResetKey();
|
||||||
|
setErrorMessage("");
|
||||||
|
}
|
||||||
|
setAdvancedUri(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToggleUseTls = useCallback(() => {
|
const handleToggleUseTls = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setUseTls((current) => !current);
|
setUseTls((current) => !current);
|
||||||
@@ -455,6 +510,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
|
|
||||||
const handleToggleAdvanced = useCallback(() => {
|
const handleToggleAdvanced = useCallback(() => {
|
||||||
if (!isAdvancedOpen) {
|
if (!isAdvancedOpen) {
|
||||||
|
advancedTarget.current = new PairingTargetTracker("", true);
|
||||||
try {
|
try {
|
||||||
setAdvancedUri(
|
setAdvancedUri(
|
||||||
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
|
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
|
||||||
@@ -612,7 +668,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||||
initialValue={advancedUri}
|
initialValue={advancedUri}
|
||||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||||
onChangeText={setAdvancedUri}
|
onChangeText={handleChangeAdvancedUri}
|
||||||
placeholder="tcp://localhost:6767?ssl=true"
|
placeholder="tcp://localhost:6767?ssl=true"
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||||
|
|
||||||
|
describe("pairing target password", () => {
|
||||||
|
it("clears the password and hides its input when switching hosts", () => {
|
||||||
|
const first = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
|
||||||
|
const second = "relay://relay.example:443/srv_b?key=BBBB&ssl=true";
|
||||||
|
const target = new PairingTargetTracker(first);
|
||||||
|
expect(target.changeUrl("relay://relay.example:443/")).toBe(false);
|
||||||
|
expect(target.changeUrl(second)).toBe(true);
|
||||||
|
expect(target.changeUrl(second)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the direct form credential when its advanced URI changes to a relay target", () => {
|
||||||
|
const target = new PairingTargetTracker("", true);
|
||||||
|
expect(target.changeUrl("relay://relay.example:443/srv_new?key=BBBB&ssl=true")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the password when a relay URI receives harmless whitespace", () => {
|
||||||
|
const uri = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
|
||||||
|
const target = new PairingTargetTracker(uri);
|
||||||
|
expect(target.changeUrl(`${uri} `)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a different host in an offer link after an incomplete edit", () => {
|
||||||
|
const offer = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
v: 2,
|
||||||
|
serverId: "srv_b",
|
||||||
|
daemonPublicKeyB64: "BBBB",
|
||||||
|
relay: { endpoint: "relay.example:443" },
|
||||||
|
}),
|
||||||
|
).toString("base64url");
|
||||||
|
const target = new PairingTargetTracker("relay://relay.example:443/srv_a?key=AAAA");
|
||||||
|
expect(target.changeUrl("https://app.paseo.sh/#offer=")).toBe(false);
|
||||||
|
expect(target.changeUrl(`https://app.paseo.sh/#offer=${offer}`)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||||
|
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
||||||
|
|
||||||
|
function pairingTarget(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
const offer =
|
||||||
|
trimmed.startsWith("relay://") || trimmed.includes("#connect=")
|
||||||
|
? parseRelayConnectionUri(trimmed).offer
|
||||||
|
: parseConnectionOfferFromUrl(trimmed);
|
||||||
|
return offer ? `${offer.serverId}:${offer.daemonPublicKeyB64}` : null;
|
||||||
|
} catch {
|
||||||
|
// Keep the last valid target while the user edits an incomplete URI.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tracks the last valid host so harmless edits never clear its password. */
|
||||||
|
export class PairingTargetTracker {
|
||||||
|
private currentTarget: string | null;
|
||||||
|
|
||||||
|
constructor(initialUrl = "", startsFromDirectForm = false) {
|
||||||
|
this.currentTarget = pairingTarget(initialUrl) ?? (startsFromDirectForm ? "direct-form" : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
changeUrl(nextUrl: string): boolean {
|
||||||
|
const nextTarget = pairingTarget(nextUrl);
|
||||||
|
if (!nextTarget) return false;
|
||||||
|
const changed = this.currentTarget !== null && this.currentTarget !== nextTarget;
|
||||||
|
this.currentTarget = nextTarget;
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Text, View } from "react-native";
|
import { Alert, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -6,15 +6,27 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { Link } from "lucide-react-native";
|
import { Link } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||||
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||||
|
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
||||||
|
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||||
|
|
||||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||||
|
|
||||||
|
function parsedHostLabel(input: string): string {
|
||||||
|
try {
|
||||||
|
if (input.startsWith("relay://") || input.includes("#connect=")) {
|
||||||
|
return parseRelayConnectionUri(input).offer.serverId;
|
||||||
|
}
|
||||||
|
return parseConnectionOfferFromUrl(input)?.serverId ?? "host";
|
||||||
|
} catch {
|
||||||
|
return "host";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
helper: {
|
helper: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
@@ -50,6 +62,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
|
|
||||||
export interface PairLinkModalProps {
|
export interface PairLinkModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
initialUrl?: string;
|
||||||
|
initialPasswordRequired?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onSaved?: (result: {
|
onSaved?: (result: {
|
||||||
@@ -60,21 +74,57 @@ export interface PairLinkModalProps {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
|
export function PairLinkModal({
|
||||||
|
visible,
|
||||||
|
initialUrl,
|
||||||
|
initialPasswordRequired = false,
|
||||||
|
onClose,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
}: PairLinkModalProps) {
|
||||||
|
return (
|
||||||
|
<PairLinkModalContent
|
||||||
|
key={`${visible}:${initialUrl ?? ""}:${initialPasswordRequired}`}
|
||||||
|
visible={visible}
|
||||||
|
initialUrl={initialUrl}
|
||||||
|
initialPasswordRequired={initialPasswordRequired}
|
||||||
|
onClose={onClose}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSaved={onSaved}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PairLinkModalContent({
|
||||||
|
visible,
|
||||||
|
initialUrl,
|
||||||
|
initialPasswordRequired = false,
|
||||||
|
onClose,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
}: PairLinkModalProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
const { probeAndUpsertConnectionFromOfferUrl } = useHostMutations();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const offerUrlRef = useRef("");
|
const offerUrlRef = useRef(initialUrl ?? "");
|
||||||
|
const targetTracker = useRef(new PairingTargetTracker(initialUrl));
|
||||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [needsPassword, setNeedsPassword] = useState(initialPasswordRequired);
|
||||||
|
const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0);
|
||||||
|
|
||||||
const clearInput = useCallback(() => {
|
const clearInput = useCallback(() => {
|
||||||
offerUrlRef.current = "";
|
offerUrlRef.current = "";
|
||||||
|
targetTracker.current = new PairingTargetTracker();
|
||||||
inputRef.current?.replaceText("");
|
inputRef.current?.replaceText("");
|
||||||
|
setPassword("");
|
||||||
|
setNeedsPassword(false);
|
||||||
|
resetPasswordInput();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pairIcon = useMemo(
|
const pairIcon = useMemo(
|
||||||
@@ -96,74 +146,63 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
|||||||
(onCancel ?? onClose)();
|
(onCancel ?? onClose)();
|
||||||
}, [isSaving, clearInput, onCancel, onClose]);
|
}, [isSaving, clearInput, onCancel, onClose]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(
|
||||||
if (isSaving) return;
|
async (input?: string) => {
|
||||||
const raw = offerUrlRef.current.trim();
|
if (isSaving) return;
|
||||||
if (!raw) {
|
const raw = (input ?? offerUrlRef.current).trim();
|
||||||
setErrorMessage(t("pairing.link.errors.required"));
|
if (!raw) {
|
||||||
return;
|
setErrorMessage(t("pairing.link.errors.required"));
|
||||||
}
|
return;
|
||||||
if (!raw.includes("#offer=")) {
|
}
|
||||||
setErrorMessage(t("pairing.link.errors.missingOffer"));
|
if (!raw.includes("#offer=") && !raw.startsWith("relay://") && !raw.includes("#connect=")) {
|
||||||
return;
|
setErrorMessage(t("pairing.link.errors.missingOffer"));
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const parsedOffer = (() => {
|
|
||||||
try {
|
try {
|
||||||
const idx = raw.indexOf("#offer=");
|
setIsSaving(true);
|
||||||
const encoded = raw.slice(idx + "#offer=".length).trim();
|
setErrorMessage("");
|
||||||
if (!encoded) {
|
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
||||||
throw new Error(t("pairing.link.errors.emptyOffer"));
|
raw,
|
||||||
}
|
password || undefined,
|
||||||
const payload = decodeOfferFragmentPayload(encoded);
|
);
|
||||||
return ConnectionOfferSchema.parse(payload);
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||||
|
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||||
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid");
|
const message =
|
||||||
|
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
|
if (getConnectionAuthFailureReason(error)) {
|
||||||
|
setNeedsPassword(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
||||||
}
|
}
|
||||||
return null;
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
})();
|
},
|
||||||
|
[
|
||||||
if (!parsedOffer) {
|
daemons,
|
||||||
return;
|
handleClose,
|
||||||
}
|
isMobile,
|
||||||
|
isSaving,
|
||||||
try {
|
onSaved,
|
||||||
setIsSaving(true);
|
password,
|
||||||
setErrorMessage("");
|
t,
|
||||||
|
probeAndUpsertConnectionFromOfferUrl,
|
||||||
const { client, hostname } = await connectToDaemon(
|
],
|
||||||
{
|
);
|
||||||
id: "probe",
|
|
||||||
type: "relay",
|
|
||||||
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
|
|
||||||
useTls: parsedOffer.relay.useTls,
|
|
||||||
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
|
|
||||||
},
|
|
||||||
{ serverId: parsedOffer.serverId },
|
|
||||||
);
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
|
|
||||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
|
||||||
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
|
|
||||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
|
||||||
handleClose();
|
|
||||||
} catch (error) {
|
|
||||||
const message =
|
|
||||||
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
|
||||||
setErrorMessage(message);
|
|
||||||
if (!isMobile) {
|
|
||||||
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
|
|
||||||
|
|
||||||
const handleChangeOfferUrl = useCallback((next: string) => {
|
const handleChangeOfferUrl = useCallback((next: string) => {
|
||||||
|
if (targetTracker.current.changeUrl(next)) {
|
||||||
|
setPassword("");
|
||||||
|
setNeedsPassword(false);
|
||||||
|
resetPasswordInput();
|
||||||
|
setErrorMessage("");
|
||||||
|
}
|
||||||
offerUrlRef.current = next;
|
offerUrlRef.current = next;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -186,6 +225,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
|||||||
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
||||||
<AdaptiveTextInput
|
<AdaptiveTextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
initialValue={initialUrl}
|
||||||
testID="pair-link-input"
|
testID="pair-link-input"
|
||||||
nativeID="pair-link-input"
|
nativeID="pair-link-input"
|
||||||
accessibilityLabel={t("pairing.link.label")}
|
accessibilityLabel={t("pairing.link.label")}
|
||||||
@@ -201,6 +241,22 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
|||||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{needsPassword ? (
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
|
||||||
|
</Text>
|
||||||
|
<AdaptiveTextInput
|
||||||
|
testID="pair-link-password-input"
|
||||||
|
resetKey={`pair-link-password-${passwordResetKey}`}
|
||||||
|
accessibilityLabel={t("pairing.hostPassword.label")}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Button
|
<Button
|
||||||
style={FLEX_ONE_STYLE}
|
style={FLEX_ONE_STYLE}
|
||||||
|
|||||||
@@ -2,14 +2,21 @@ import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "luc
|
|||||||
import { type ReactNode, useMemo } from "react";
|
import { type ReactNode, useMemo } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
|
type ButtonControlSize,
|
||||||
|
buttonIconSize,
|
||||||
|
createControlGeometry,
|
||||||
|
} from "@/components/ui/control-geometry";
|
||||||
|
import { hexColorWithAlpha } from "@/utils/color";
|
||||||
|
|
||||||
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
|
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
|
||||||
|
export type AlertSize = ButtonControlSize;
|
||||||
|
|
||||||
export interface AlertProps {
|
export interface AlertProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
variant?: AlertVariant;
|
variant?: AlertVariant;
|
||||||
icon?: ReactNode;
|
size?: AlertSize;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
@@ -25,50 +32,93 @@ export function Alert({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
icon,
|
size = "md",
|
||||||
children,
|
children,
|
||||||
testID,
|
testID,
|
||||||
}: AlertProps) {
|
}: AlertProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const accentColor = resolveAccentColor(variant, theme);
|
const accentColor = resolveAccentColor(variant, theme);
|
||||||
const borderColor = variant === "success" ? theme.colors.border : accentColor;
|
const borderColor =
|
||||||
|
variant === "success" || !accentColor
|
||||||
|
? theme.colors.border
|
||||||
|
: hexColorWithAlpha(accentColor, 0.5);
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const sized = resolveSizeStyles(size);
|
||||||
() => [styles.container, borderColor ? { borderColor } : null],
|
const containerStyle = [styles.container, sized.container, { borderColor }];
|
||||||
[borderColor],
|
const titleStyle = [styles.title, sized.text, accentColor ? { color: accentColor } : null];
|
||||||
);
|
const iconSize = buttonIconSize[size];
|
||||||
|
|
||||||
const titleStyle = useMemo(
|
|
||||||
() => [styles.title, accentColor ? { color: accentColor } : null],
|
|
||||||
[accentColor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolvedIcon = useMemo(() => {
|
const resolvedIcon = useMemo(() => {
|
||||||
if (icon !== undefined) return icon;
|
|
||||||
if (variant === "default") return null;
|
if (variant === "default") return null;
|
||||||
const Icon = VARIANT_ICON[variant];
|
const Icon = VARIANT_ICON[variant];
|
||||||
return <Icon size={theme.iconSize.sm} color={accentColor ?? theme.colors.foreground} />;
|
return <Icon size={iconSize} color={accentColor ?? theme.colors.foreground} />;
|
||||||
}, [icon, variant, theme, accentColor]);
|
}, [variant, theme, accentColor, iconSize]);
|
||||||
|
|
||||||
const hasDescription = description != null && description !== "";
|
let descriptionContent: ReactNode = null;
|
||||||
|
if (typeof description === "string" && description !== "") {
|
||||||
|
descriptionContent = <Text style={[styles.description, sized.text]}>{description}</Text>;
|
||||||
|
} else if (description != null && description !== "") {
|
||||||
|
descriptionContent = <View style={styles.descriptionSlot}>{description}</View>;
|
||||||
|
}
|
||||||
|
const leadContent = title ? <Text style={titleStyle}>{title}</Text> : descriptionContent;
|
||||||
|
const belowLead = title ? descriptionContent : null;
|
||||||
|
const hasBody = belowLead !== null || Boolean(children);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle} testID={testID} accessibilityRole="alert">
|
<View style={containerStyle} testID={testID} accessibilityRole="alert">
|
||||||
{resolvedIcon ? <View style={styles.iconSlot}>{resolvedIcon}</View> : null}
|
<View style={[styles.lead, sized.lead]}>
|
||||||
<View style={styles.body}>
|
{resolvedIcon ? (
|
||||||
{title ? <Text style={titleStyle}>{title}</Text> : null}
|
<View style={[styles.iconSlot, sized.iconSlot]}>{resolvedIcon}</View>
|
||||||
{hasDescription && typeof description === "string" ? (
|
|
||||||
<Text style={styles.description}>{description}</Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
{hasDescription && typeof description !== "string" ? (
|
{leadContent ? <View style={styles.leadText}>{leadContent}</View> : null}
|
||||||
<View style={styles.descriptionSlot}>{description}</View>
|
|
||||||
) : null}
|
|
||||||
{children ? <View style={styles.actions}>{children}</View> : null}
|
|
||||||
</View>
|
</View>
|
||||||
|
{hasBody ? (
|
||||||
|
<View style={resolvedIcon ? sized.indent : null}>
|
||||||
|
{belowLead}
|
||||||
|
{children ? <View style={styles.actions}>{children}</View> : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSizeStyles(size: AlertSize) {
|
||||||
|
if (size === "xs") {
|
||||||
|
return {
|
||||||
|
container: styles.containerXs,
|
||||||
|
text: styles.textXs,
|
||||||
|
lead: styles.leadXs,
|
||||||
|
iconSlot: styles.iconSlotXs,
|
||||||
|
indent: styles.indentXs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (size === "sm") {
|
||||||
|
return {
|
||||||
|
container: styles.containerSm,
|
||||||
|
text: styles.textSm,
|
||||||
|
lead: styles.leadSm,
|
||||||
|
iconSlot: styles.iconSlotSm,
|
||||||
|
indent: styles.indentSm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (size === "lg") {
|
||||||
|
return {
|
||||||
|
container: styles.containerLg,
|
||||||
|
text: styles.textLg,
|
||||||
|
lead: styles.leadLg,
|
||||||
|
iconSlot: styles.iconSlotLg,
|
||||||
|
indent: styles.indentLg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
container: styles.containerMd,
|
||||||
|
text: styles.textMd,
|
||||||
|
lead: styles.leadMd,
|
||||||
|
iconSlot: styles.iconSlotMd,
|
||||||
|
indent: styles.indentMd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAccentColor(
|
function resolveAccentColor(
|
||||||
variant: AlertVariant,
|
variant: AlertVariant,
|
||||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||||
@@ -80,43 +130,63 @@ function resolveAccentColor(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => {
|
||||||
container: {
|
const { alert } = createControlGeometry(theme);
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "flex-start",
|
return {
|
||||||
gap: theme.spacing[3],
|
container: {
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
borderRadius: theme.borderRadius.xl,
|
},
|
||||||
paddingVertical: theme.spacing[3],
|
containerXs: alert.xs.container,
|
||||||
paddingHorizontal: theme.spacing[4],
|
containerSm: alert.sm.container,
|
||||||
},
|
containerMd: alert.md.container,
|
||||||
iconSlot: {
|
containerLg: alert.lg.container,
|
||||||
paddingTop: 2,
|
lead: {
|
||||||
},
|
flexDirection: "row",
|
||||||
body: {
|
alignItems: "center",
|
||||||
flex: 1,
|
},
|
||||||
minWidth: 0,
|
iconSlot: {
|
||||||
gap: theme.spacing[1],
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
title: {
|
leadText: {
|
||||||
color: theme.colors.foreground,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.base,
|
minWidth: 0,
|
||||||
fontWeight: theme.fontWeight.medium,
|
},
|
||||||
},
|
leadXs: alert.xs.lead,
|
||||||
description: {
|
leadSm: alert.sm.lead,
|
||||||
color: theme.colors.foregroundMuted,
|
leadMd: alert.md.lead,
|
||||||
fontSize: theme.fontSize.sm,
|
leadLg: alert.lg.lead,
|
||||||
},
|
iconSlotXs: alert.xs.iconSlot,
|
||||||
descriptionSlot: {
|
iconSlotSm: alert.sm.iconSlot,
|
||||||
flexShrink: 1,
|
iconSlotMd: alert.md.iconSlot,
|
||||||
minWidth: 0,
|
iconSlotLg: alert.lg.iconSlot,
|
||||||
gap: theme.spacing[2],
|
indentXs: alert.xs.indent,
|
||||||
},
|
indentSm: alert.sm.indent,
|
||||||
actions: {
|
indentMd: alert.md.indent,
|
||||||
flexDirection: "row",
|
indentLg: alert.lg.indent,
|
||||||
gap: theme.spacing[2],
|
textXs: alert.xs.text,
|
||||||
marginTop: theme.spacing[2],
|
textSm: alert.sm.text,
|
||||||
},
|
textMd: alert.md.text,
|
||||||
}));
|
textLg: alert.lg.text,
|
||||||
|
title: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
descriptionSlot: {
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buttonControlHeight,
|
buttonControlHeight,
|
||||||
|
buttonIconSize,
|
||||||
createControlGeometry,
|
createControlGeometry,
|
||||||
getControlInteractionPhase,
|
getControlInteractionPhase,
|
||||||
} from "@/components/ui/control-geometry";
|
} from "@/components/ui/control-geometry";
|
||||||
@@ -11,6 +12,7 @@ const theme = {
|
|||||||
md: 6,
|
md: 6,
|
||||||
lg: 8,
|
lg: 8,
|
||||||
xl: 12,
|
xl: 12,
|
||||||
|
"2xl": 16,
|
||||||
full: 9999,
|
full: 9999,
|
||||||
},
|
},
|
||||||
borderWidth: {
|
borderWidth: {
|
||||||
@@ -30,10 +32,13 @@ const theme = {
|
|||||||
},
|
},
|
||||||
spacing: {
|
spacing: {
|
||||||
0: 0,
|
0: 0,
|
||||||
|
0.5: 2,
|
||||||
|
1: 4,
|
||||||
2: 8,
|
2: 8,
|
||||||
3: 12,
|
3: 12,
|
||||||
4: 16,
|
4: 16,
|
||||||
6: 24,
|
6: 24,
|
||||||
|
8: 32,
|
||||||
},
|
},
|
||||||
} as unknown as Theme;
|
} as unknown as Theme;
|
||||||
|
|
||||||
@@ -157,4 +162,41 @@ describe("control geometry", () => {
|
|||||||
geometry.buttonMd.paddingHorizontal,
|
geometry.buttonMd.paddingHorizontal,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives alert title and description one font size and a tight gap per size", () => {
|
||||||
|
const { alert } = createControlGeometry(theme);
|
||||||
|
|
||||||
|
expect(alert.xs.text).toEqual({ fontSize: 12 });
|
||||||
|
expect(alert.sm.text).toEqual({ fontSize: 14 });
|
||||||
|
expect(alert.md.text).toEqual(alert.sm.text);
|
||||||
|
expect(alert.lg.text).toEqual(alert.sm.text);
|
||||||
|
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
||||||
|
expect(alert[size].container.gap).toBeLessThanOrEqual(4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts alert text below the lead row at the lead text's left edge", () => {
|
||||||
|
const { alert } = createControlGeometry(theme);
|
||||||
|
|
||||||
|
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
||||||
|
expect(alert[size].iconSlot.width).toBe(buttonIconSize[size]);
|
||||||
|
expect(alert[size].indent.marginLeft).toBe(alert[size].iconSlot.width + alert[size].lead.gap);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps sm alerts as roomy and round as the original alert, and scales the rest around it", () => {
|
||||||
|
const { alert } = createControlGeometry(theme);
|
||||||
|
|
||||||
|
expect(alert.sm.container).toMatchObject({
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 16,
|
||||||
|
});
|
||||||
|
expect(alert.xs.container.paddingVertical).toBeLessThan(alert.sm.container.paddingVertical);
|
||||||
|
expect(alert.md.container.paddingVertical).toBeGreaterThan(alert.sm.container.paddingVertical);
|
||||||
|
expect(alert.lg.container.paddingVertical).toBeGreaterThan(alert.md.container.paddingVertical);
|
||||||
|
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
||||||
|
expect(alert[size].container.borderRadius).toBeGreaterThanOrEqual(12);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,6 +87,77 @@ function fieldVerticalPadding(
|
|||||||
return (controlHeight - lineHeight - borderWidth * 2) / 2;
|
return (controlHeight - lineHeight - borderWidth * 2) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alerts share the button's size names, text size, and icon size (`buttonIconSize`).
|
||||||
|
* The icon and the first text sit in one centered lead row; everything below is indented
|
||||||
|
* by the icon slot plus its gap so it starts at the lead text's left edge. Title and
|
||||||
|
* description use one font size; only the container gap separates them.
|
||||||
|
*/
|
||||||
|
function createAlertGeometry(theme: Theme) {
|
||||||
|
function alertSize(input: {
|
||||||
|
size: ButtonControlSize;
|
||||||
|
fontSize: number;
|
||||||
|
paddingVertical: number;
|
||||||
|
paddingHorizontal: number;
|
||||||
|
borderRadius: number;
|
||||||
|
iconGap: number;
|
||||||
|
textGap: number;
|
||||||
|
}) {
|
||||||
|
const iconSize = buttonIconSize[input.size];
|
||||||
|
return {
|
||||||
|
container: {
|
||||||
|
paddingVertical: input.paddingVertical,
|
||||||
|
paddingHorizontal: input.paddingHorizontal,
|
||||||
|
borderRadius: input.borderRadius,
|
||||||
|
gap: input.textGap,
|
||||||
|
},
|
||||||
|
lead: { gap: input.iconGap },
|
||||||
|
iconSlot: { width: iconSize },
|
||||||
|
indent: { marginLeft: iconSize + input.iconGap },
|
||||||
|
text: { fontSize: input.fontSize },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
xs: alertSize({
|
||||||
|
size: "xs",
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.xl,
|
||||||
|
iconGap: theme.spacing[2],
|
||||||
|
textGap: 0,
|
||||||
|
}),
|
||||||
|
sm: alertSize({
|
||||||
|
size: "sm",
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
|
iconGap: theme.spacing[3],
|
||||||
|
textGap: theme.spacing[0.5],
|
||||||
|
}),
|
||||||
|
md: alertSize({
|
||||||
|
size: "md",
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
paddingHorizontal: theme.spacing[6],
|
||||||
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
|
iconGap: theme.spacing[3],
|
||||||
|
textGap: theme.spacing[0.5],
|
||||||
|
}),
|
||||||
|
lg: alertSize({
|
||||||
|
size: "lg",
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
paddingVertical: theme.spacing[6],
|
||||||
|
paddingHorizontal: theme.spacing[8],
|
||||||
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
|
iconGap: theme.spacing[4],
|
||||||
|
textGap: theme.spacing[1],
|
||||||
|
}),
|
||||||
|
} satisfies Record<ButtonControlSize, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export function getControlInteractionPhase(
|
export function getControlInteractionPhase(
|
||||||
state: ControlInteractionState,
|
state: ControlInteractionState,
|
||||||
): ControlInteractionPhase {
|
): ControlInteractionPhase {
|
||||||
@@ -147,6 +218,7 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
lineHeight: fieldTextMdLineHeight,
|
lineHeight: fieldTextMdLineHeight,
|
||||||
};
|
};
|
||||||
|
const alert = createAlertGeometry(theme);
|
||||||
const switchControl = {
|
const switchControl = {
|
||||||
minHeight: CONTROL_HEIGHTS.compact,
|
minHeight: CONTROL_HEIGHTS.compact,
|
||||||
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
||||||
@@ -179,6 +251,7 @@ export function createControlGeometry(theme: Theme) {
|
|||||||
buttonTextXs: {
|
buttonTextXs: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
|
alert,
|
||||||
formTextInputSm: {
|
formTextInputSm: {
|
||||||
...fieldControlSm,
|
...fieldControlSm,
|
||||||
...fieldTextSm,
|
...fieldTextSm,
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export function DesktopNotificationsSection() {
|
|||||||
</View>
|
</View>
|
||||||
{testNotificationState.status === "success" ? (
|
{testNotificationState.status === "success" ? (
|
||||||
<Alert
|
<Alert
|
||||||
|
size="sm"
|
||||||
variant="success"
|
variant="success"
|
||||||
title={t("settings.notifications.sentTitle")}
|
title={t("settings.notifications.sentTitle")}
|
||||||
description={t("settings.notifications.sentDescription")}
|
description={t("settings.notifications.sentDescription")}
|
||||||
@@ -139,6 +140,7 @@ export function DesktopNotificationsSection() {
|
|||||||
) : null}
|
) : null}
|
||||||
{testNotificationState.status === "error" ? (
|
{testNotificationState.status === "error" ? (
|
||||||
<Alert
|
<Alert
|
||||||
|
size="sm"
|
||||||
variant="error"
|
variant="error"
|
||||||
title={t("settings.notifications.sendFailedTitle")}
|
title={t("settings.notifications.sendFailedTitle")}
|
||||||
description={testNotificationState.message}
|
description={testNotificationState.message}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
|
|||||||
function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Alert variant="error" description={message}>
|
<Alert size="sm" variant="error" description={message}>
|
||||||
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
|
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
|
||||||
{t("pairing.device.retry")}
|
{t("pairing.device.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -197,9 +197,11 @@ function RelayConsent(props: PairDeviceBodyProps) {
|
|||||||
accessibilityLabel={t("pairing.device.relayDocsAccessibility")}
|
accessibilityLabel={t("pairing.device.relayDocsAccessibility")}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{props.enableError ? <Alert variant="error" description={props.enableError.message} /> : null}
|
{props.enableError ? (
|
||||||
|
<Alert size="sm" variant="error" description={props.enableError.message} />
|
||||||
|
) : null}
|
||||||
{!props.canConfigureRelay ? (
|
{!props.canConfigureRelay ? (
|
||||||
<Alert variant="warning" description={t("pairing.device.updateRequired")} />
|
<Alert size="sm" variant="warning" description={t("pairing.device.updateRequired")} />
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Button variant="secondary" style={FLEX_ONE_STYLE} onPress={props.onClose}>
|
<Button variant="secondary" style={FLEX_ONE_STYLE} onPress={props.onClose}>
|
||||||
@@ -262,7 +264,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
|
|||||||
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
|
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<Alert variant="warning" description={t("pairing.device.securityWarning")} />
|
<Alert size="sm" variant="warning" description={t("pairing.device.securityWarning")} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { HostConnection } from "@/types/host-connection";
|
||||||
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
|
||||||
|
function connectionListen(connection: HostConnection): string | null {
|
||||||
|
switch (connection.type) {
|
||||||
|
case "directTcp":
|
||||||
|
return connection.endpoint;
|
||||||
|
case "directSocket":
|
||||||
|
return `unix://${connection.path}`;
|
||||||
|
case "directPipe":
|
||||||
|
return `pipe://${connection.path}`;
|
||||||
|
case "remoteSsh":
|
||||||
|
case "relay":
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop main checks that this target matches the live desktop-managed daemon.
|
||||||
|
// A plain browser has no bridge and falls back to its saved host password.
|
||||||
|
export async function readDesktopManagedLocalCredential(
|
||||||
|
connection: HostConnection,
|
||||||
|
invoke: (listen: string) => Promise<unknown> = async (listen) =>
|
||||||
|
getDesktopHost()?.invoke?.("desktop_local_credential", { listen }),
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const listen = connectionListen(connection);
|
||||||
|
if (!listen) return undefined;
|
||||||
|
const credential = await invoke(listen);
|
||||||
|
return typeof credential === "string" ? credential : undefined;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { defaultHostAppearance } from "@/hosts/appearance";
|
|||||||
function makeHost(): HostProfile {
|
function makeHost(): HostProfile {
|
||||||
return {
|
return {
|
||||||
serverId: "srv-secret",
|
serverId: "srv-secret",
|
||||||
|
password: "tcp-password",
|
||||||
label: "Secret host",
|
label: "Secret host",
|
||||||
appearance: defaultHostAppearance(),
|
appearance: defaultHostAppearance(),
|
||||||
lifecycle: {},
|
lifecycle: {},
|
||||||
@@ -23,7 +24,6 @@ function makeHost(): HostProfile {
|
|||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint: "secret.example.test:6767",
|
endpoint: "secret.example.test:6767",
|
||||||
useTls: true,
|
useTls: true,
|
||||||
password: "tcp-password",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "relay:relay.secret.test:443",
|
id: "relay:relay.secret.test:443",
|
||||||
|
|||||||
@@ -125,11 +125,11 @@ export function redactAppDiagnosticReport(report: string, hosts: HostProfile[]):
|
|||||||
function collectSensitiveHostValues(hosts: HostProfile[]): string[] {
|
function collectSensitiveHostValues(hosts: HostProfile[]): string[] {
|
||||||
const values = new Set<string>();
|
const values = new Set<string>();
|
||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
|
if (host.password) values.add(host.password);
|
||||||
for (const connection of host.connections) {
|
for (const connection of host.connections) {
|
||||||
values.add(connection.id);
|
values.add(connection.id);
|
||||||
if (connection.type === "directTcp") {
|
if (connection.type === "directTcp") {
|
||||||
values.add(connection.endpoint);
|
values.add(connection.endpoint);
|
||||||
if (connection.password) values.add(connection.password);
|
|
||||||
} else if (connection.type === "relay") {
|
} else if (connection.type === "relay") {
|
||||||
values.add(connection.relayEndpoint);
|
values.add(connection.relayEndpoint);
|
||||||
values.add(connection.daemonPublicKeyB64);
|
values.add(connection.daemonPublicKeyB64);
|
||||||
|
|||||||
@@ -1613,6 +1613,10 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
|
hostPassword: {
|
||||||
|
title: "كلمة المرور لـ {{host}}",
|
||||||
|
label: "كلمة مرور المضيف",
|
||||||
|
},
|
||||||
connectionMethods: {
|
connectionMethods: {
|
||||||
title: "إضافة اتصال",
|
title: "إضافة اتصال",
|
||||||
direct: {
|
direct: {
|
||||||
@@ -2294,6 +2298,9 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
password: {
|
||||||
|
guidance: "أزل هذا المضيف ثم أضفه مرة أخرى بكلمة المرور التي يطلبها هذا الخادم.",
|
||||||
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
title: "المظهر",
|
title: "المظهر",
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -1638,6 +1638,10 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
|
hostPassword: {
|
||||||
|
title: "Password for {{host}}",
|
||||||
|
label: "Host password",
|
||||||
|
},
|
||||||
connectionMethods: {
|
connectionMethods: {
|
||||||
title: "Add connection",
|
title: "Add connection",
|
||||||
direct: {
|
direct: {
|
||||||
@@ -2413,6 +2417,9 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
password: {
|
||||||
|
guidance: "Remove this host and add it again with the password this daemon asks for.",
|
||||||
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
title: "Appearance",
|
title: "Appearance",
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -1659,6 +1659,10 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
|
hostPassword: {
|
||||||
|
title: "Contraseña de {{host}}",
|
||||||
|
label: "Contraseña del host",
|
||||||
|
},
|
||||||
connectionMethods: {
|
connectionMethods: {
|
||||||
title: "Agregar conexión",
|
title: "Agregar conexión",
|
||||||
direct: {
|
direct: {
|
||||||
@@ -2348,6 +2352,9 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
password: {
|
||||||
|
guidance: "Elimina este host y vuelve a añadirlo con la contraseña que pide este daemon.",
|
||||||
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
title: "Apariencia",
|
title: "Apariencia",
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -1663,6 +1663,10 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
|
hostPassword: {
|
||||||
|
title: "Mot de passe pour {{host}}",
|
||||||
|
label: "Mot de passe de l’hôte",
|
||||||
|
},
|
||||||
connectionMethods: {
|
connectionMethods: {
|
||||||
title: "Ajouter une connexion",
|
title: "Ajouter une connexion",
|
||||||
direct: {
|
direct: {
|
||||||
@@ -2352,6 +2356,10 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
password: {
|
||||||
|
guidance:
|
||||||
|
"Supprimez cet hôte puis ajoutez-le à nouveau avec le mot de passe demandé par ce daemon.",
|
||||||
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
title: "Apparence",
|
title: "Apparence",
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -1630,6 +1630,10 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
|
hostPassword: {
|
||||||
|
title: "{{host}} のパスワード",
|
||||||
|
label: "ホストのパスワード",
|
||||||
|
},
|
||||||
connectionMethods: {
|
connectionMethods: {
|
||||||
title: "接続を追加",
|
title: "接続を追加",
|
||||||
direct: {
|
direct: {
|
||||||
@@ -2315,6 +2319,10 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
password: {
|
||||||
|
guidance:
|
||||||
|
"このホストを削除し、このデーモンが求めるパスワードを入力して追加し直してください。",
|
||||||
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
title: "外観",
|
title: "外観",
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -1623,6 +1623,10 @@ export const ko: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
|
hostPassword: {
|
||||||
|
title: "{{host}}의 비밀번호",
|
||||||
|
label: "호스트 비밀번호",
|
||||||
|
},
|
||||||
connectionMethods: {
|
connectionMethods: {
|
||||||
title: "연결 추가",
|
title: "연결 추가",
|
||||||
direct: {
|
direct: {
|
||||||
@@ -2304,6 +2308,9 @@ export const ko: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
password: {
|
||||||
|
guidance: "이 호스트를 제거한 뒤 이 데몬이 요구하는 비밀번호로 다시 추가하세요.",
|
||||||
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
title: "모양",
|
title: "모양",
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -1644,6 +1644,10 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
|
hostPassword: {
|
||||||
|
title: "Senha de {{host}}",
|
||||||
|
label: "Senha do host",
|
||||||
|
},
|
||||||
connectionMethods: {
|
connectionMethods: {
|
||||||
title: "Adicionar conexão",
|
title: "Adicionar conexão",
|
||||||
direct: {
|
direct: {
|
||||||
@@ -2330,6 +2334,9 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
password: {
|
||||||
|
guidance: "Remova este host e adicione-o novamente com a senha que este daemon pede.",
|
||||||
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
title: "Aparência",
|
title: "Aparência",
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -1642,6 +1642,10 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
|
hostPassword: {
|
||||||
|
title: "Пароль для {{host}}",
|
||||||
|
label: "Пароль хоста",
|
||||||
|
},
|
||||||
connectionMethods: {
|
connectionMethods: {
|
||||||
title: "Добавить подключение",
|
title: "Добавить подключение",
|
||||||
direct: {
|
direct: {
|
||||||
@@ -2334,6 +2338,10 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
password: {
|
||||||
|
guidance:
|
||||||
|
"Удалите этот хост и добавьте его снова с паролем, который запрашивает этот демон.",
|
||||||
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
title: "Оформление",
|
title: "Оформление",
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -1596,6 +1596,10 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
|
hostPassword: {
|
||||||
|
title: "{{host}} 的密码",
|
||||||
|
label: "主机密码",
|
||||||
|
},
|
||||||
connectionMethods: {
|
connectionMethods: {
|
||||||
title: "添加连接",
|
title: "添加连接",
|
||||||
direct: {
|
direct: {
|
||||||
@@ -2268,6 +2272,9 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
host: {
|
||||||
|
password: {
|
||||||
|
guidance: "移除此主机,然后使用此守护进程要求的密码重新添加。",
|
||||||
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
title: "外观",
|
title: "外观",
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service";
|
import type { DaemonStartResult, StartDaemonIfEnabledInput } from "@/runtime/daemon-start-service";
|
||||||
|
|
||||||
describe("startHostRuntimeBootstrap", () => {
|
describe("startHostRuntimeBootstrap", () => {
|
||||||
it("boots the host registry and starts the managed-daemon decision as one operation", async () => {
|
it("boots the host registry and starts the managed-daemon decision independently", async () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const shouldStartDaemon = async () => true;
|
const shouldStartDaemon = async () => true;
|
||||||
const store = {
|
const store = {
|
||||||
@@ -40,9 +40,10 @@ describe("startHostRuntimeBootstrap", () => {
|
|||||||
|
|
||||||
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
||||||
expect(await receivedDecisions[0]).toBe(true);
|
expect(await receivedDecisions[0]).toBe(true);
|
||||||
|
expect(events).toEqual(["boot", "daemon-start-decision"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waits for the host registry to load before evaluating managed-daemon startup", async () => {
|
it("loads the registry before evaluating managed-daemon startup", async () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
let resolveBoot!: () => void;
|
let resolveBoot!: () => void;
|
||||||
const booted = new Promise<void>((resolve) => {
|
const booted = new Promise<void>((resolve) => {
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ function BlockedSetupNotice({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Alert
|
<Alert
|
||||||
|
size="sm"
|
||||||
variant="warning"
|
variant="warning"
|
||||||
title={t("workspace.setup.blocked.title")}
|
title={t("workspace.setup.blocked.title")}
|
||||||
description={t("workspace.setup.blocked.description", {
|
description={t("workspace.setup.blocked.description", {
|
||||||
@@ -158,7 +159,7 @@ function BlockedSetupNotice({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</Alert>
|
</Alert>
|
||||||
{runError ? <Alert variant="error" description={runError} /> : null}
|
{runError ? <Alert size="sm" variant="error" description={runError} /> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,12 @@ function ProviderUsageBody({
|
|||||||
|
|
||||||
if (view.kind === "error") {
|
if (view.kind === "error") {
|
||||||
return (
|
return (
|
||||||
<Alert variant="error" title={providerUsageCopy.errorTitle} description={view.message}>
|
<Alert
|
||||||
|
size="sm"
|
||||||
|
variant="error"
|
||||||
|
title={providerUsageCopy.errorTitle}
|
||||||
|
description={view.message}
|
||||||
|
>
|
||||||
<Button variant="outline" size="sm" onPress={onRefresh}>
|
<Button variant="outline" size="sm" onPress={onRefresh}>
|
||||||
{providerUsageCopy.retry}
|
{providerUsageCopy.retry}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -325,6 +325,15 @@ describe("upsertDesktopDaemonConnection", () => {
|
|||||||
expect(fake.upserts).toEqual([]);
|
expect(fake.upserts).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps an already registered host without waiting for a listen address", async () => {
|
||||||
|
const fake = createFakeStore([makeRelayOnlyHost("srv_desktop")]);
|
||||||
|
|
||||||
|
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: null }));
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(fake.upserts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a missing listen address without upserting", async () => {
|
it("rejects a missing listen address without upserting", async () => {
|
||||||
const fake = createFakeStore();
|
const fake = createFakeStore();
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export async function upsertDesktopDaemonConnection(
|
|||||||
if (store.getHosts().some((host) => host.serverId === serverId)) {
|
if (store.getHosts().some((host) => host.serverId === serverId)) {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const listenAddress = daemon.listen?.trim() ?? "";
|
const listenAddress = daemon.listen?.trim() ?? "";
|
||||||
if (!listenAddress) {
|
if (!listenAddress) {
|
||||||
return { ok: false, error: "Desktop daemon did not return a listen address." };
|
return { ok: false, error: "Desktop daemon did not return a listen address." };
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { AppStateStatus } from "react-native";
|
import type { AppStateStatus } from "react-native";
|
||||||
import { bindHostRuntimeAppState } from "@/navigation/host-runtime-bootstrap";
|
import {
|
||||||
|
bindHostRuntimeAppState,
|
||||||
|
startHostRuntimeBootstrap,
|
||||||
|
} from "@/navigation/host-runtime-bootstrap";
|
||||||
import type {
|
import type {
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
FetchAgentsEntry,
|
FetchAgentsEntry,
|
||||||
FetchAgentsOptions,
|
FetchAgentsOptions,
|
||||||
} from "@getpaseo/client/internal/daemon-client";
|
} from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||||
@@ -26,11 +30,31 @@ import {
|
|||||||
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
||||||
|
|
||||||
import { subscriptionFixture } from "./subscription-fixture";
|
import { subscriptionFixture } from "./subscription-fixture";
|
||||||
|
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||||
|
|
||||||
|
it("requests the managed connection credential through desktop main without a web hint", async () => {
|
||||||
|
const requests: string[] = [];
|
||||||
|
const connection: HostConnection = {
|
||||||
|
id: "direct:localhost:6767",
|
||||||
|
type: "directTcp",
|
||||||
|
endpoint: "localhost:6767",
|
||||||
|
};
|
||||||
|
let token = "local-token";
|
||||||
|
const invoke = async (listen: string) => {
|
||||||
|
requests.push(listen);
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
expect(await readDesktopManagedLocalCredential(connection, invoke)).toBe("local-token");
|
||||||
|
token = "rotated-token";
|
||||||
|
expect(await readDesktopManagedLocalCredential(connection, invoke)).toBe("rotated-token");
|
||||||
|
expect(requests).toEqual(["localhost:6767", "localhost:6767"]);
|
||||||
|
});
|
||||||
|
|
||||||
class FakeDaemonClient {
|
class FakeDaemonClient {
|
||||||
private state: ConnectionState = { status: "idle" };
|
private state: ConnectionState = { status: "idle" };
|
||||||
private listeners = new Set<(status: ConnectionState) => void>();
|
private listeners = new Set<(status: ConnectionState) => void>();
|
||||||
private error: string | null = null;
|
private error: string | null = null;
|
||||||
|
authFailureReason: "password_required" | "incorrect_password" | null = null;
|
||||||
private heartbeatRttMs: number | null = null;
|
private heartbeatRttMs: number | null = null;
|
||||||
private latencyMeasurementFailure: Error | null = null;
|
private latencyMeasurementFailure: Error | null = null;
|
||||||
private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = [];
|
private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = [];
|
||||||
@@ -381,6 +405,7 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
serverId: input?.serverId ?? "srv_test",
|
serverId: input?.serverId ?? "srv_test",
|
||||||
|
...(input?.password ? { password: input.password } : {}),
|
||||||
label: input?.label ?? "test host",
|
label: input?.label ?? "test host",
|
||||||
appearance: input?.appearance ?? defaultHostAppearance(),
|
appearance: input?.appearance ?? defaultHostAppearance(),
|
||||||
lifecycle: input?.lifecycle ?? {},
|
lifecycle: input?.lifecycle ?? {},
|
||||||
@@ -559,6 +584,21 @@ function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onceHostSnapshotMatches(
|
||||||
|
store: HostRuntimeStore,
|
||||||
|
serverId: string,
|
||||||
|
predicate: (snapshot: ReturnType<HostRuntimeStore["getSnapshot"]>) => boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (predicate(store.getSnapshot(serverId))) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const unsubscribe = store.subscribe(serverId, () => {
|
||||||
|
if (!predicate(store.getSnapshot(serverId))) return;
|
||||||
|
unsubscribe();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
class BrowserClientLifecycle {
|
class BrowserClientLifecycle {
|
||||||
public active: Array<{ serverId: string; connectionId: string }> = [];
|
public active: Array<{ serverId: string; connectionId: string }> = [];
|
||||||
|
|
||||||
@@ -3350,7 +3390,7 @@ describe("HostRuntimeStore", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("upsertDirectConnection stores SSL and password settings", async () => {
|
it("upsertDirectConnection stores SSL on the connection and password on the host", async () => {
|
||||||
const store = new HostRuntimeStore({
|
const store = new HostRuntimeStore({
|
||||||
deps: {
|
deps: {
|
||||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
@@ -3378,9 +3418,9 @@ describe("HostRuntimeStore", () => {
|
|||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint: "example.paseo.test:7443",
|
endpoint: "example.paseo.test:7443",
|
||||||
useTls: true,
|
useTls: true,
|
||||||
password: "shared-secret",
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
expect(host?.password).toBe("shared-secret");
|
||||||
|
|
||||||
store.syncHosts([]);
|
store.syncHosts([]);
|
||||||
});
|
});
|
||||||
@@ -3557,6 +3597,33 @@ describe("HostRuntimeStore", () => {
|
|||||||
store.syncHosts([]);
|
store.syncHosts([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("probes a pairing link immediately and saves only after admission", async () => {
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
deps: {
|
||||||
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async ({ host }) => {
|
||||||
|
if (host.password !== "correct-password")
|
||||||
|
throw new DaemonAuthenticationError("password_required");
|
||||||
|
return {
|
||||||
|
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||||
|
serverId: host.serverId,
|
||||||
|
hostname: "paired host",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_pairing",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const offerUrl = encodeOfferUrl(makeOffer());
|
||||||
|
await expect(store.probeAndUpsertConnectionFromOfferUrl(offerUrl)).rejects.toThrow(
|
||||||
|
"Password required",
|
||||||
|
);
|
||||||
|
expect(store.getHosts()).toHaveLength(0);
|
||||||
|
const result = await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password");
|
||||||
|
expect(result.serverId).toBe("srv_offer");
|
||||||
|
expect(store.getHosts()[0]?.password).toBe("correct-password");
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
||||||
const store = new HostRuntimeStore({
|
const store = new HostRuntimeStore({
|
||||||
deps: {
|
deps: {
|
||||||
@@ -3625,6 +3692,322 @@ describe("readInitialDaemonConnectionHint", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||||
|
it("starts saved remote-host probes while desktop daemon startup is still pending", async () => {
|
||||||
|
const host = makeHost({
|
||||||
|
serverId: "srv_remote_saved",
|
||||||
|
connections: [
|
||||||
|
{ id: "direct:remote.example:6799", type: "directTcp", endpoint: "remote.example:6799" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const storage = createMemoryHostRuntimeStorage();
|
||||||
|
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||||
|
let remoteProbeStarted = false;
|
||||||
|
let finishDesktopStart: (() => void) | undefined;
|
||||||
|
const desktopStartPending = new Promise<void>((resolve) => {
|
||||||
|
finishDesktopStart = resolve;
|
||||||
|
});
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
storage,
|
||||||
|
deps: {
|
||||||
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async () => {
|
||||||
|
remoteProbeStarted = true;
|
||||||
|
return {
|
||||||
|
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||||
|
serverId: host.serverId,
|
||||||
|
hostname: host.label,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_remote_saved",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
startHostRuntimeBootstrap({
|
||||||
|
store,
|
||||||
|
shouldStartDaemon: true,
|
||||||
|
daemonStartService: {
|
||||||
|
startIfEnabled: async () => {
|
||||||
|
await desktopStartPending;
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await vi.waitFor(() => expect(remoteProbeStarted).toBe(true), { timeout: 400 });
|
||||||
|
expect(store.isHostRegistryLoaded()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
finishDesktopStart?.();
|
||||||
|
await store.boot();
|
||||||
|
store.syncHosts([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("resolves a local credential for a reopened saved desktop host connection", async () => {
|
||||||
|
const host = makeHost({
|
||||||
|
serverId: "srv_desktop_reopened",
|
||||||
|
connections: [{ id: "direct:localhost:6799", type: "directTcp", endpoint: "localhost:6799" }],
|
||||||
|
});
|
||||||
|
const storage = createMemoryHostRuntimeStorage();
|
||||||
|
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||||
|
const credentialRequests: string[] = [];
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
storage,
|
||||||
|
deps: {
|
||||||
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async ({ host: probeHost, connection }) => {
|
||||||
|
expect(probeHost.password).toBeUndefined();
|
||||||
|
const credential = await readDesktopManagedLocalCredential(connection, async (listen) => {
|
||||||
|
credentialRequests.push(listen);
|
||||||
|
return listen === "localhost:6799" ? "local-token" : null;
|
||||||
|
});
|
||||||
|
expect(credential).toBe("local-token");
|
||||||
|
return {
|
||||||
|
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||||
|
serverId: host.serverId,
|
||||||
|
hostname: host.label,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_desktop_reopened",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
startHostRuntimeBootstrap({
|
||||||
|
store,
|
||||||
|
shouldStartDaemon: true,
|
||||||
|
daemonStartService: {
|
||||||
|
startIfEnabled: async () => ({ ok: true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await waitForHostOnline(store, host.serverId);
|
||||||
|
expect(credentialRequests).toContain("localhost:6799");
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
it("requires a new password and stops reconnecting after a previously connected client is rejected", async () => {
|
||||||
|
const client = makeConnectedProbeClient(5);
|
||||||
|
const host = makeHost({
|
||||||
|
serverId: "srv_changed_password",
|
||||||
|
password: "old-password",
|
||||||
|
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
|
||||||
|
});
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
storage: createMemoryHostRuntimeStorage(),
|
||||||
|
deps: {
|
||||||
|
createClient: () => client as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async () => ({
|
||||||
|
client: client as unknown as DaemonClient,
|
||||||
|
serverId: host.serverId,
|
||||||
|
hostname: host.label,
|
||||||
|
}),
|
||||||
|
getClientId: async () => "cid_changed_password",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store.syncHosts([host], {
|
||||||
|
initialConnectionByServerId: new Map([
|
||||||
|
[
|
||||||
|
host.serverId,
|
||||||
|
{
|
||||||
|
connectionId: host.connections[0]!.id,
|
||||||
|
existingClient: client as unknown as DaemonClient,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
await waitForHostOnline(store, host.serverId);
|
||||||
|
client.authFailureReason = "incorrect_password";
|
||||||
|
client.setConnectionState({ status: "disconnected", reason: "Incorrect password" });
|
||||||
|
await onceHostSnapshotMatches(
|
||||||
|
store,
|
||||||
|
host.serverId,
|
||||||
|
(snapshot) => snapshot?.authFailureReason === "incorrect_password",
|
||||||
|
);
|
||||||
|
expect(store.getSnapshot(host.serverId)?.lastError).toBe("Incorrect password");
|
||||||
|
expect(client.reconnectEnabledChanges.at(-1)).toBe(false);
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tries another connection after the active connection rejects its password", async () => {
|
||||||
|
const relayClient = makeConnectedProbeClient(5);
|
||||||
|
const directClient = makeConnectedProbeClient(5);
|
||||||
|
const host = makeHost({
|
||||||
|
serverId: "srv_rejected_relay_with_local_direct",
|
||||||
|
password: "stale-password",
|
||||||
|
preferredConnectionId: "relay:remote:443",
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
id: "relay:remote:443",
|
||||||
|
type: "relay",
|
||||||
|
relayEndpoint: "remote:443",
|
||||||
|
daemonPublicKeyB64: "pk_test_offer",
|
||||||
|
},
|
||||||
|
{ id: "direct:localhost:6799", type: "directTcp", endpoint: "localhost:6799" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const probeAttempts: string[] = [];
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
storage: createMemoryHostRuntimeStorage(),
|
||||||
|
deps: {
|
||||||
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async ({ connection }) => {
|
||||||
|
probeAttempts.push(connection.id);
|
||||||
|
if (connection.type === "relay")
|
||||||
|
throw new DaemonAuthenticationError("incorrect_password");
|
||||||
|
return {
|
||||||
|
client: directClient as unknown as DaemonClient,
|
||||||
|
serverId: host.serverId,
|
||||||
|
hostname: host.label,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_rejected_relay_with_local_direct",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store.syncHosts([host], {
|
||||||
|
initialConnectionByServerId: new Map([
|
||||||
|
[
|
||||||
|
host.serverId,
|
||||||
|
{
|
||||||
|
connectionId: "relay:remote:443",
|
||||||
|
existingClient: relayClient as unknown as DaemonClient,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
await waitForHostOnline(store, host.serverId);
|
||||||
|
relayClient.authFailureReason = "incorrect_password";
|
||||||
|
relayClient.setConnectionState({ status: "disconnected", reason: "Incorrect password" });
|
||||||
|
await onceHostSnapshotMatches(
|
||||||
|
store,
|
||||||
|
host.serverId,
|
||||||
|
(snapshot) => snapshot?.authFailureReason === "incorrect_password",
|
||||||
|
);
|
||||||
|
await store.runProbeCycleNow(host.serverId);
|
||||||
|
await onceHostSnapshotMatches(
|
||||||
|
store,
|
||||||
|
host.serverId,
|
||||||
|
(snapshot) =>
|
||||||
|
snapshot?.connectionStatus === "online" &&
|
||||||
|
snapshot.activeConnectionId === "direct:localhost:6799",
|
||||||
|
);
|
||||||
|
expect(probeAttempts).toContain("direct:localhost:6799");
|
||||||
|
expect(probeAttempts).not.toContain("relay:remote:443");
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps five rejected hosts red with independent password errors", async () => {
|
||||||
|
let probeAttempts = 0;
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
storage: createMemoryHostRuntimeStorage(),
|
||||||
|
deps: {
|
||||||
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async () => {
|
||||||
|
probeAttempts += 1;
|
||||||
|
throw new DaemonAuthenticationError("password_required");
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_five_rejected_hosts",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (let index = 1; index <= 5; index += 1) {
|
||||||
|
const serverId = `srv_rejected_${index}`;
|
||||||
|
await store.upsertDirectConnection({ serverId, endpoint: `host-${index}:6767` });
|
||||||
|
await onceHostSnapshotMatches(
|
||||||
|
store,
|
||||||
|
serverId,
|
||||||
|
(snapshot) => snapshot?.authFailureReason === "password_required",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let index = 1; index <= 5; index += 1) {
|
||||||
|
const snapshot = store.getSnapshot(`srv_rejected_${index}`);
|
||||||
|
expect(snapshot?.connectionStatus).toBe("error");
|
||||||
|
expect(snapshot?.lastError).toBe("Password required");
|
||||||
|
}
|
||||||
|
const attemptsBeforeRetry = probeAttempts;
|
||||||
|
await store.runProbeCycleNow("srv_rejected_1");
|
||||||
|
expect(probeAttempts).toBe(attemptsBeforeRetry);
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
it("imports a pairing link after a password retry in the add flow", async () => {
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
storage: createMemoryHostRuntimeStorage(),
|
||||||
|
deps: {
|
||||||
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async ({ host }) => {
|
||||||
|
if (host.password !== "correct-password")
|
||||||
|
throw new DaemonAuthenticationError("password_required");
|
||||||
|
return {
|
||||||
|
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||||
|
serverId: host.serverId,
|
||||||
|
hostname: "paired",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_link_import",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
|
||||||
|
await expect(store.importConnectionLink(link, "hostRoot")).resolves.toEqual({
|
||||||
|
status: "password_required",
|
||||||
|
});
|
||||||
|
expect(store.getHosts()).toHaveLength(0);
|
||||||
|
await store.probeAndUpsertConnectionFromOfferUrl(link, "correct-password");
|
||||||
|
expect(store.getHosts()[0]).toMatchObject({
|
||||||
|
serverId: "srv_pair",
|
||||||
|
password: "correct-password",
|
||||||
|
});
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
it("imports a deep link without waiting for admission, then leaves a rejected saved host red", async () => {
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
storage: createMemoryHostRuntimeStorage(),
|
||||||
|
deps: {
|
||||||
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async () => {
|
||||||
|
throw new DaemonAuthenticationError("password_required");
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_deep_link",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const link = "relay://relay.example:443/srv_deep?key=AAAA&ssl=true";
|
||||||
|
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
|
||||||
|
status: "connected",
|
||||||
|
serverId: "srv_deep",
|
||||||
|
});
|
||||||
|
expect(store.getHosts()).toHaveLength(1);
|
||||||
|
await onceHostSnapshotMatches(
|
||||||
|
store,
|
||||||
|
"srv_deep",
|
||||||
|
(snapshot) => snapshot?.authFailureReason === "password_required",
|
||||||
|
);
|
||||||
|
expect(store.getSnapshot("srv_deep")?.connectionStatus).toBe("error");
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
it("saves and reconnects a rejected saved host after changing its password", async () => {
|
||||||
|
const store = new HostRuntimeStore({
|
||||||
|
storage: createMemoryHostRuntimeStorage(),
|
||||||
|
deps: {
|
||||||
|
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||||
|
connectToDaemon: async ({ host }) => {
|
||||||
|
if (host.password !== "correct-password")
|
||||||
|
throw new DaemonAuthenticationError("password_required");
|
||||||
|
return {
|
||||||
|
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||||
|
serverId: host.serverId,
|
||||||
|
hostname: host.label,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getClientId: async () => "cid_test_password_setting",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await store.upsertDirectConnection({ serverId: "srv_test", endpoint: "lan:6767" });
|
||||||
|
await onceHostSnapshotMatches(
|
||||||
|
store,
|
||||||
|
"srv_test",
|
||||||
|
(snapshot) => snapshot?.authFailureReason === "password_required",
|
||||||
|
);
|
||||||
|
expect(store.getSnapshot("srv_test")?.connectionStatus).toBe("error");
|
||||||
|
await store.setHostPassword("srv_test", "correct-password");
|
||||||
|
await waitForHostOnline(store, "srv_test");
|
||||||
|
expect(store.getHosts()[0]?.password).toBe("correct-password");
|
||||||
|
expect(store.getSnapshot("srv_test")?.authFailureReason).toBeNull();
|
||||||
|
await store.setHostPassword("srv_test", "");
|
||||||
|
expect(store.getHosts()[0]?.password).toBeUndefined();
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("attempts the explicit initial connection hint before default localhost bootstrap", async () => {
|
it("attempts the explicit initial connection hint before default localhost bootstrap", async () => {
|
||||||
const seenProbes: { endpoint: string; useTls?: boolean }[] = [];
|
const seenProbes: { endpoint: string; useTls?: boolean }[] = [];
|
||||||
const store = new HostRuntimeStore({
|
const store = new HostRuntimeStore({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
|||||||
import equal from "fast-deep-equal/es6";
|
import equal from "fast-deep-equal/es6";
|
||||||
import {
|
import {
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
|
DaemonAuthenticationError,
|
||||||
type DaemonClientConfig,
|
type DaemonClientConfig,
|
||||||
type ConnectionState,
|
type ConnectionState,
|
||||||
type FetchAgentsOptions,
|
type FetchAgentsOptions,
|
||||||
@@ -23,13 +24,14 @@ import {
|
|||||||
buildRelayWebSocketUrl,
|
buildRelayWebSocketUrl,
|
||||||
decodeOfferFragmentPayload,
|
decodeOfferFragmentPayload,
|
||||||
normalizeHostPort,
|
normalizeHostPort,
|
||||||
|
parseRelayConnectionUri,
|
||||||
shouldUseTlsForDefaultHostedRelay,
|
shouldUseTlsForDefaultHostedRelay,
|
||||||
} from "@/utils/daemon-endpoints";
|
} from "@/utils/daemon-endpoints";
|
||||||
import { resolveAppVersion } from "@/utils/app-version";
|
import { resolveAppVersion } from "@/utils/app-version";
|
||||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
||||||
import { getOrCreateClientId } from "@/utils/client-id";
|
import { getOrCreateClientId } from "@/utils/client-id";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { readValidatedJson, readValidatedString } from "@/storage/validated-storage";
|
import { readValidatedJson, readValidatedString } from "@/storage/validated-storage";
|
||||||
@@ -43,6 +45,7 @@ import {
|
|||||||
createDesktopDaemonTransportFactory,
|
createDesktopDaemonTransportFactory,
|
||||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||||
import {
|
import {
|
||||||
@@ -82,6 +85,7 @@ import { revokePushNotifications } from "@/push-notifications";
|
|||||||
import { createAppWebSocketFactory } from "./websocket-factory";
|
import { createAppWebSocketFactory } from "./websocket-factory";
|
||||||
|
|
||||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||||
|
export type PairingNavigationTarget = "openProject" | "hostRoot" | "hostSettings";
|
||||||
export type HostRegistryStatus = "loading" | "ready";
|
export type HostRegistryStatus = "loading" | "ready";
|
||||||
|
|
||||||
export type ActiveConnection =
|
export type ActiveConnection =
|
||||||
@@ -106,6 +110,7 @@ export interface HostRuntimeSnapshot {
|
|||||||
connectionStatus: HostRuntimeConnectionStatus;
|
connectionStatus: HostRuntimeConnectionStatus;
|
||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
|
authFailureReason?: "password_required" | "incorrect_password" | null;
|
||||||
lastOnlineAt: string | null;
|
lastOnlineAt: string | null;
|
||||||
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
|
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
|
||||||
agentDirectoryError: string | null;
|
agentDirectoryError: string | null;
|
||||||
@@ -517,6 +522,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
capabilities: appCapabilities,
|
capabilities: appCapabilities,
|
||||||
trace: nativePerformanceTrace,
|
trace: nativePerformanceTrace,
|
||||||
providerSnapshots: "wire",
|
providerSnapshots: "wire",
|
||||||
|
...(host.password ? { password: host.password } : {}),
|
||||||
|
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||||
} satisfies Omit<DaemonClientConfig, "url">;
|
} satisfies Omit<DaemonClientConfig, "url">;
|
||||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
@@ -550,7 +557,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
url: buildDaemonWebSocketUrl(connection.endpoint, {
|
url: buildDaemonWebSocketUrl(connection.endpoint, {
|
||||||
useTls: connection.useTls ?? false,
|
useTls: connection.useTls ?? false,
|
||||||
}),
|
}),
|
||||||
...(connection.password ? { password: connection.password } : {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
@@ -570,6 +576,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
connectToDaemon: ({ host, connection, timeoutMs }) =>
|
connectToDaemon: ({ host, connection, timeoutMs }) =>
|
||||||
connectToDaemon(connection, {
|
connectToDaemon(connection, {
|
||||||
...(host.serverId ? { serverId: host.serverId } : {}),
|
...(host.serverId ? { serverId: host.serverId } : {}),
|
||||||
|
...(host.password ? { password: host.password } : {}),
|
||||||
|
localCredential: () => readDesktopManagedLocalCredential(connection),
|
||||||
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
||||||
capabilities: appCapabilities,
|
capabilities: appCapabilities,
|
||||||
trace: nativePerformanceTrace,
|
trace: nativePerformanceTrace,
|
||||||
@@ -617,6 +625,7 @@ export class HostRuntimeController {
|
|||||||
private switchRequestVersion = 0;
|
private switchRequestVersion = 0;
|
||||||
private probeRequestVersion = 0;
|
private probeRequestVersion = 0;
|
||||||
private probeCycleInFlight: Promise<void> | null = null;
|
private probeCycleInFlight: Promise<void> | null = null;
|
||||||
|
private readonly authRejectedConnectionIds = new Set<string>();
|
||||||
|
|
||||||
constructor(input: {
|
constructor(input: {
|
||||||
host: HostProfile;
|
host: HostProfile;
|
||||||
@@ -705,6 +714,12 @@ export class HostRuntimeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateHost(host: HostProfile): Promise<void> {
|
async updateHost(host: HostProfile): Promise<void> {
|
||||||
|
const passwordChanged = this.host.password !== host.password;
|
||||||
|
if (passwordChanged) {
|
||||||
|
this.probeRequestVersion += 1;
|
||||||
|
this.authRejectedConnectionIds.clear();
|
||||||
|
this.updateSnapshot({ authFailureReason: null });
|
||||||
|
}
|
||||||
const activeConnectionId = this.snapshot.activeConnectionId;
|
const activeConnectionId = this.snapshot.activeConnectionId;
|
||||||
const previousActiveConnection = findConnectionById(this.host, activeConnectionId);
|
const previousActiveConnection = findConnectionById(this.host, activeConnectionId);
|
||||||
this.host = host;
|
this.host = host;
|
||||||
@@ -714,11 +729,13 @@ export class HostRuntimeController {
|
|||||||
activeConnectionId &&
|
activeConnectionId &&
|
||||||
previousActiveConnection &&
|
previousActiveConnection &&
|
||||||
nextActiveConnection &&
|
nextActiveConnection &&
|
||||||
!equal(previousActiveConnection, nextActiveConnection)
|
(!equal(previousActiveConnection, nextActiveConnection) || passwordChanged)
|
||||||
) {
|
) {
|
||||||
this.connectionLastProbedAt.delete(activeConnectionId);
|
this.connectionLastProbedAt.delete(activeConnectionId);
|
||||||
await this.switchToConnection({ connectionId: activeConnectionId });
|
await this.switchToConnection({ connectionId: activeConnectionId });
|
||||||
}
|
}
|
||||||
|
if (passwordChanged) this.connectionLastProbedAt.clear();
|
||||||
|
if (passwordChanged) await this.probeCycleInFlight;
|
||||||
await this.runProbeCycleNow();
|
await this.runProbeCycleNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,6 +825,7 @@ export class HostRuntimeController {
|
|||||||
const hasActiveOnlineConnection = isOnline && activeConnectionId !== null;
|
const hasActiveOnlineConnection = isOnline && activeConnectionId !== null;
|
||||||
|
|
||||||
const connectionsToProbe = this.host.connections.filter((connection) => {
|
const connectionsToProbe = this.host.connections.filter((connection) => {
|
||||||
|
if (this.authRejectedConnectionIds.has(connection.id)) return false;
|
||||||
const lastProbed = this.connectionLastProbedAt.get(connection.id);
|
const lastProbed = this.connectionLastProbedAt.get(connection.id);
|
||||||
if (lastProbed == null) {
|
if (lastProbed == null) {
|
||||||
return true;
|
return true;
|
||||||
@@ -843,6 +861,7 @@ export class HostRuntimeController {
|
|||||||
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
|
this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) });
|
||||||
|
|
||||||
let remaining = connectionsToProbe.length;
|
let remaining = connectionsToProbe.length;
|
||||||
|
let probeAuthFailure: "password_required" | "incorrect_password" | null = null;
|
||||||
let activationLock: Promise<void> | null = null;
|
let activationLock: Promise<void> | null = null;
|
||||||
|
|
||||||
const publishProbeState = (): void => {
|
const publishProbeState = (): void => {
|
||||||
@@ -893,11 +912,18 @@ export class HostRuntimeController {
|
|||||||
connectionId: nextConnectionId,
|
connectionId: nextConnectionId,
|
||||||
expectedProbeVersion: requestVersion,
|
expectedProbeVersion: requestVersion,
|
||||||
});
|
});
|
||||||
|
} else if (probeAuthFailure) {
|
||||||
|
const message = new DaemonAuthenticationError(probeAuthFailure).message;
|
||||||
|
this.applyConnectionEvent({ type: "connect_failed", message });
|
||||||
|
this.updateSnapshot({
|
||||||
|
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||||
|
authFailureReason: probeAuthFailure,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeProbe?.status === "unavailable") {
|
if (this.isConnectionUnavailable(currentActiveConnectionId, activeProbe)) {
|
||||||
const nextConnectionId = selectBestConnection({
|
const nextConnectionId = selectBestConnection({
|
||||||
candidates: buildConnectionCandidates(this.host),
|
candidates: buildConnectionCandidates(this.host),
|
||||||
probeByConnectionId,
|
probeByConnectionId,
|
||||||
@@ -1030,8 +1056,13 @@ export class HostRuntimeController {
|
|||||||
latencyMs: rttMs,
|
latencyMs: rttMs,
|
||||||
});
|
});
|
||||||
publishProbeState();
|
publishProbeState();
|
||||||
} catch {
|
} catch (error) {
|
||||||
if (this.isCurrentProbeRequest(requestVersion)) {
|
if (this.isCurrentProbeRequest(requestVersion)) {
|
||||||
|
const authFailure = getConnectionAuthFailureReason(error);
|
||||||
|
if (authFailure) {
|
||||||
|
this.authRejectedConnectionIds.add(connection.id);
|
||||||
|
probeAuthFailure = authFailure;
|
||||||
|
}
|
||||||
probeByConnectionId.set(connection.id, {
|
probeByConnectionId.set(connection.id, {
|
||||||
status: "unavailable",
|
status: "unavailable",
|
||||||
latencyMs: null,
|
latencyMs: null,
|
||||||
@@ -1049,6 +1080,13 @@ export class HostRuntimeController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isConnectionUnavailable(
|
||||||
|
connectionId: string,
|
||||||
|
probe: ConnectionProbeState | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return probe?.status === "unavailable" || this.authRejectedConnectionIds.has(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
|
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
|
||||||
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
|
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
|
||||||
let hasChanged = this.host.serverId !== this.snapshot.serverId;
|
let hasChanged = this.host.serverId !== this.snapshot.serverId;
|
||||||
@@ -1257,6 +1295,8 @@ export class HostRuntimeController {
|
|||||||
|
|
||||||
const failConnection = async (error: unknown) => {
|
const failConnection = async (error: unknown) => {
|
||||||
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return;
|
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return;
|
||||||
|
const authFailure = getConnectionAuthFailureReason(error);
|
||||||
|
if (authFailure) this.authRejectedConnectionIds.add(connection.id);
|
||||||
this.unsubscribeClientStatus?.();
|
this.unsubscribeClientStatus?.();
|
||||||
this.unsubscribeClientStatus = null;
|
this.unsubscribeClientStatus = null;
|
||||||
this.unsubscribeClientHandlers?.();
|
this.unsubscribeClientHandlers?.();
|
||||||
@@ -1266,12 +1306,29 @@ export class HostRuntimeController {
|
|||||||
this.updateSnapshot({
|
this.updateSnapshot({
|
||||||
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||||
client: null,
|
client: null,
|
||||||
|
authFailureReason: authFailure,
|
||||||
});
|
});
|
||||||
|
if (authFailure) {
|
||||||
|
const probes = new Map(this.snapshot.probeByConnectionId);
|
||||||
|
probes.set(connection.id, { status: "unavailable", latencyMs: null });
|
||||||
|
this.updateSnapshot({ probeByConnectionId: probes });
|
||||||
|
for (const candidate of this.host.connections) {
|
||||||
|
if (!this.authRejectedConnectionIds.has(candidate.id)) {
|
||||||
|
this.connectionLastProbedAt.delete(candidate.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await client.close();
|
await client.close();
|
||||||
} catch {
|
} catch {
|
||||||
/* Preserve the compatibility/connection error. */
|
/* Preserve the compatibility/connection error. */
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
authFailure &&
|
||||||
|
this.host.connections.some((candidate) => !this.authRejectedConnectionIds.has(candidate.id))
|
||||||
|
) {
|
||||||
|
void this.runProbeCycleNow();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
if (!existingClient) await client.connect();
|
if (!existingClient) await client.connect();
|
||||||
@@ -1280,11 +1337,16 @@ export class HostRuntimeController {
|
|||||||
this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null;
|
this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null;
|
||||||
this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => {
|
this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => {
|
||||||
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return;
|
if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return;
|
||||||
|
if (client.authFailureReason) {
|
||||||
|
void failConnection(new DaemonAuthenticationError(client.authFailureReason));
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError });
|
this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError });
|
||||||
this.updateSnapshot({
|
this.updateSnapshot({
|
||||||
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch),
|
||||||
...this.buildAgentDirectoryStatusPatch(),
|
...this.buildAgentDirectoryStatusPatch(),
|
||||||
client,
|
client,
|
||||||
|
authFailureReason: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1345,6 +1407,19 @@ function readConfiguredLocalDaemonOverride(): string | null {
|
|||||||
return value && value.length > 0 ? value : null;
|
return value && value.length > 0 ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; password?: string } {
|
||||||
|
if (input.trim().startsWith("relay://") || input.includes("#connect=")) {
|
||||||
|
return parseRelayConnectionUri(input);
|
||||||
|
}
|
||||||
|
const marker = "#offer=";
|
||||||
|
const idx = input.indexOf(marker);
|
||||||
|
if (idx === -1) throw new Error("Missing #offer= fragment");
|
||||||
|
const encoded = input.slice(idx + marker.length).trim();
|
||||||
|
if (!encoded) throw new Error("Offer payload is empty");
|
||||||
|
const payload = decodeOfferFragmentPayload(encoded);
|
||||||
|
return { offer: ConnectionOfferSchema.parse(payload) };
|
||||||
|
}
|
||||||
|
|
||||||
export function hasConfiguredLocalDaemonOverride(): boolean {
|
export function hasConfiguredLocalDaemonOverride(): boolean {
|
||||||
return readConfiguredLocalDaemonOverride() !== null;
|
return readConfiguredLocalDaemonOverride() !== null;
|
||||||
}
|
}
|
||||||
@@ -1495,6 +1570,14 @@ export class HostRuntimeStore {
|
|||||||
normalizedProfiles.length = 0;
|
normalizedProfiles.length = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// COMPAT(connectionPassword): added in v0.9.1, remove after 2027-03-24 with stored-password migration.
|
||||||
|
if (
|
||||||
|
entry.connections.some(
|
||||||
|
(connection) => connection.type === "directTcp" && connection.password,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
shouldPersistHosts = true;
|
||||||
|
}
|
||||||
normalizedProfiles.push(profile);
|
normalizedProfiles.push(profile);
|
||||||
}
|
}
|
||||||
profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId));
|
profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId));
|
||||||
@@ -1717,12 +1800,12 @@ export class HostRuntimeStore {
|
|||||||
return this.upsertHostConnection({
|
return this.upsertHostConnection({
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
label: input.label,
|
label: input.label,
|
||||||
|
password,
|
||||||
connection: {
|
connection: {
|
||||||
id: `direct:${endpoint}`,
|
id: `direct:${endpoint}`,
|
||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint,
|
endpoint,
|
||||||
useTls: input.useTls ?? false,
|
useTls: input.useTls ?? false,
|
||||||
...(password ? { password } : {}),
|
|
||||||
},
|
},
|
||||||
existingClient: input.existingClient,
|
existingClient: input.existingClient,
|
||||||
});
|
});
|
||||||
@@ -1730,6 +1813,7 @@ export class HostRuntimeStore {
|
|||||||
|
|
||||||
async probeAndUpsertConnection(input: {
|
async probeAndUpsertConnection(input: {
|
||||||
connection: HostConnection;
|
connection: HostConnection;
|
||||||
|
password?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||||
@@ -1738,6 +1822,7 @@ export class HostRuntimeStore {
|
|||||||
}
|
}
|
||||||
const probeHost: HostProfile = {
|
const probeHost: HostProfile = {
|
||||||
serverId: "",
|
serverId: "",
|
||||||
|
...(input.password ? { password: input.password } : {}),
|
||||||
label: input.label ?? input.connection.id,
|
label: input.label ?? input.connection.id,
|
||||||
appearance: defaultHostAppearance(),
|
appearance: defaultHostAppearance(),
|
||||||
lifecycle: {},
|
lifecycle: {},
|
||||||
@@ -1754,6 +1839,7 @@ export class HostRuntimeStore {
|
|||||||
const profile = await this.upsertHostConnection({
|
const profile = await this.upsertHostConnection({
|
||||||
serverId,
|
serverId,
|
||||||
label: input.label ?? hostname ?? undefined,
|
label: input.label ?? hostname ?? undefined,
|
||||||
|
password: input.password,
|
||||||
connection: input.connection,
|
connection: input.connection,
|
||||||
existingClient: client,
|
existingClient: client,
|
||||||
});
|
});
|
||||||
@@ -1770,12 +1856,12 @@ export class HostRuntimeStore {
|
|||||||
const password = input.password?.trim();
|
const password = input.password?.trim();
|
||||||
return this.probeAndUpsertConnection({
|
return this.probeAndUpsertConnection({
|
||||||
label: input.label,
|
label: input.label,
|
||||||
|
password,
|
||||||
connection: {
|
connection: {
|
||||||
id: `direct:${endpoint}`,
|
id: `direct:${endpoint}`,
|
||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint,
|
endpoint,
|
||||||
useTls: input.useTls ?? false,
|
useTls: input.useTls ?? false,
|
||||||
...(password ? { password } : {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1798,6 +1884,7 @@ export class HostRuntimeStore {
|
|||||||
useTls?: boolean;
|
useTls?: boolean;
|
||||||
daemonPublicKeyB64: string;
|
daemonPublicKeyB64: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
password?: string;
|
||||||
}): Promise<HostProfile> {
|
}): Promise<HostProfile> {
|
||||||
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
|
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
|
||||||
const useTls = input.useTls ?? false;
|
const useTls = input.useTls ?? false;
|
||||||
@@ -1806,7 +1893,7 @@ export class HostRuntimeStore {
|
|||||||
throw new Error("daemonPublicKeyB64 is required");
|
throw new Error("daemonPublicKeyB64 is required");
|
||||||
}
|
}
|
||||||
const explicitUseTls = input.useTls !== undefined;
|
const explicitUseTls = input.useTls !== undefined;
|
||||||
return this.upsertHostConnection({
|
const profile = await this.upsertHostConnection({
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
label: input.label,
|
label: input.label,
|
||||||
connection: {
|
connection: {
|
||||||
@@ -1817,9 +1904,18 @@ export class HostRuntimeStore {
|
|||||||
daemonPublicKeyB64,
|
daemonPublicKeyB64,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (input.password) {
|
||||||
|
await this.setHostPassword(profile.serverId, input.password);
|
||||||
|
return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile;
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise<HostProfile> {
|
async upsertConnectionFromOffer(
|
||||||
|
offer: ConnectionOffer,
|
||||||
|
label?: string,
|
||||||
|
password?: string,
|
||||||
|
): Promise<HostProfile> {
|
||||||
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
|
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
|
||||||
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
||||||
return this.upsertRelayConnection({
|
return this.upsertRelayConnection({
|
||||||
@@ -1828,25 +1924,67 @@ export class HostRuntimeStore {
|
|||||||
useTls,
|
useTls,
|
||||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||||
label,
|
label,
|
||||||
|
password,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertConnectionFromOfferUrl(
|
async upsertConnectionFromOfferUrl(
|
||||||
offerUrlOrFragment: string,
|
offerUrlOrFragment: string,
|
||||||
label?: string,
|
label?: string,
|
||||||
|
password?: string,
|
||||||
): Promise<HostProfile> {
|
): Promise<HostProfile> {
|
||||||
const marker = "#offer=";
|
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
|
||||||
const idx = offerUrlOrFragment.indexOf(marker);
|
return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password);
|
||||||
if (idx === -1) {
|
}
|
||||||
throw new Error("Missing #offer= fragment");
|
|
||||||
|
async probeAndUpsertConnectionFromOfferUrl(
|
||||||
|
offerUrlOrFragment: string,
|
||||||
|
password?: string,
|
||||||
|
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||||
|
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
|
||||||
|
const offer = parsed.offer;
|
||||||
|
const credential = password ?? parsed.password;
|
||||||
|
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
||||||
|
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
|
||||||
|
const connection: HostConnection = {
|
||||||
|
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
|
||||||
|
type: "relay",
|
||||||
|
relayEndpoint,
|
||||||
|
useTls,
|
||||||
|
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||||
|
};
|
||||||
|
const probeHost: HostProfile = {
|
||||||
|
serverId: offer.serverId,
|
||||||
|
...(credential ? { password: credential } : {}),
|
||||||
|
label: offer.serverId,
|
||||||
|
appearance: defaultHostAppearance(),
|
||||||
|
lifecycle: {},
|
||||||
|
connections: [connection],
|
||||||
|
preferredConnectionId: connection.id,
|
||||||
|
createdAt: new Date(0).toISOString(),
|
||||||
|
updatedAt: new Date(0).toISOString(),
|
||||||
|
};
|
||||||
|
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, credential);
|
||||||
|
return { profile, serverId: offer.serverId, hostname };
|
||||||
|
}
|
||||||
|
|
||||||
|
async importConnectionLink(
|
||||||
|
url: string,
|
||||||
|
target: PairingNavigationTarget,
|
||||||
|
): Promise<{ status: "connected"; serverId: string } | { status: "password_required" }> {
|
||||||
|
if (target === "openProject") {
|
||||||
|
const profile = await this.upsertConnectionFromOfferUrl(url);
|
||||||
|
return { status: "connected", serverId: profile.serverId };
|
||||||
}
|
}
|
||||||
const encoded = offerUrlOrFragment.slice(idx + marker.length).trim();
|
try {
|
||||||
if (!encoded) {
|
const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl(url);
|
||||||
throw new Error("Offer payload is empty");
|
return { status: "connected", serverId };
|
||||||
|
} catch (error) {
|
||||||
|
if (!getConnectionAuthFailureReason(error)) throw error;
|
||||||
|
return { status: "password_required" };
|
||||||
}
|
}
|
||||||
const payload = decodeOfferFragmentPayload(encoded);
|
|
||||||
const offer = ConnectionOfferSchema.parse(payload);
|
|
||||||
return this.upsertConnectionFromOffer(offer, label);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertConnectionFromListen(input: {
|
async upsertConnectionFromListen(input: {
|
||||||
@@ -1886,6 +2024,13 @@ export class HostRuntimeStore {
|
|||||||
await this.updateHost(serverId, (host) => ({ ...host, label }));
|
await this.updateHost(serverId, (host) => ({ ...host, label }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setHostPassword(serverId: string, password: string): Promise<void> {
|
||||||
|
await this.updateHost(serverId, (host) => {
|
||||||
|
const { password: _previous, ...rest } = host;
|
||||||
|
return password ? { ...rest, password } : rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async setHostColor(serverId: string, color: HostColor): Promise<void> {
|
async setHostColor(serverId: string, color: HostColor): Promise<void> {
|
||||||
await this.updateHostAppearance(serverId, (host) => ({
|
await this.updateHostAppearance(serverId, (host) => ({
|
||||||
...host,
|
...host,
|
||||||
@@ -1963,6 +2108,7 @@ export class HostRuntimeStore {
|
|||||||
private async upsertHostConnection(input: {
|
private async upsertHostConnection(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
password?: string;
|
||||||
connection: HostConnection;
|
connection: HostConnection;
|
||||||
existingClient?: DaemonClient;
|
existingClient?: DaemonClient;
|
||||||
}): Promise<HostProfile> {
|
}): Promise<HostProfile> {
|
||||||
@@ -1972,6 +2118,7 @@ export class HostRuntimeStore {
|
|||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
label: input.label,
|
label: input.label,
|
||||||
connection: input.connection,
|
connection: input.connection,
|
||||||
|
password: input.password,
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
this.setHostsAndSync(next, {
|
this.setHostsAndSync(next, {
|
||||||
@@ -2605,12 +2752,18 @@ export interface HostMutations {
|
|||||||
useTls?: boolean;
|
useTls?: boolean;
|
||||||
daemonPublicKeyB64: string;
|
daemonPublicKeyB64: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
password?: string;
|
||||||
}) => Promise<HostProfile>;
|
}) => Promise<HostProfile>;
|
||||||
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
|
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
|
||||||
upsertConnectionFromOfferUrl: (
|
upsertConnectionFromOfferUrl: (
|
||||||
offerUrlOrFragment: string,
|
offerUrlOrFragment: string,
|
||||||
label?: string,
|
label?: string,
|
||||||
|
password?: string,
|
||||||
) => Promise<HostProfile>;
|
) => Promise<HostProfile>;
|
||||||
|
probeAndUpsertConnectionFromOfferUrl: (
|
||||||
|
offerUrlOrFragment: string,
|
||||||
|
password?: string,
|
||||||
|
) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||||
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
||||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
|
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
|
||||||
@@ -2627,7 +2780,10 @@ export function useHostMutations(): HostMutations {
|
|||||||
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
|
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
|
||||||
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
|
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
|
||||||
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
||||||
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
|
upsertConnectionFromOfferUrl: (url, label, password) =>
|
||||||
|
store.upsertConnectionFromOfferUrl(url, label, password),
|
||||||
|
probeAndUpsertConnectionFromOfferUrl: (url, password) =>
|
||||||
|
store.probeAndUpsertConnectionFromOfferUrl(url, password),
|
||||||
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
||||||
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
|
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
|
||||||
setHostBadgeDisplay: (serverId, badgeDisplay) =>
|
setHostBadgeDisplay: (serverId, badgeDisplay) =>
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ function ReadFailureCallout({ kind, error, onReload }: ReadFailureCalloutProps)
|
|||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<View style={styles.errorBlock}>
|
<View style={styles.errorBlock}>
|
||||||
<Alert testID={testID} variant="error" title={title} description={description}>
|
<Alert size="md" testID={testID} variant="error" title={title} description={description}>
|
||||||
<Button testID={`${testID}-action-0`} onPress={onReload} variant="outline" size="sm">
|
<Button testID={`${testID}-action-0`} onPress={onReload} variant="outline" size="sm">
|
||||||
{t("settings.project.actions.reload")}
|
{t("settings.project.actions.reload")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -702,6 +702,7 @@ function ProjectConfigForm({
|
|||||||
>
|
>
|
||||||
{hasUncommittedWorktreeSetupChanges ? (
|
{hasUncommittedWorktreeSetupChanges ? (
|
||||||
<Alert
|
<Alert
|
||||||
|
size="sm"
|
||||||
variant="warning"
|
variant="warning"
|
||||||
title={t("settings.project.worktree.uncommittedTitle")}
|
title={t("settings.project.worktree.uncommittedTitle")}
|
||||||
description={t("settings.project.worktree.uncommittedDescription")}
|
description={t("settings.project.worktree.uncommittedDescription")}
|
||||||
@@ -776,6 +777,7 @@ function ProjectConfigForm({
|
|||||||
{isStale ? (
|
{isStale ? (
|
||||||
<View style={styles.calloutWrap}>
|
<View style={styles.calloutWrap}>
|
||||||
<Alert
|
<Alert
|
||||||
|
size="sm"
|
||||||
testID="stale-callout"
|
testID="stale-callout"
|
||||||
variant="error"
|
variant="error"
|
||||||
title={t("settings.project.writeFailures.staleTitle")}
|
title={t("settings.project.writeFailures.staleTitle")}
|
||||||
@@ -796,6 +798,7 @@ function ProjectConfigForm({
|
|||||||
{isWriteFailed ? (
|
{isWriteFailed ? (
|
||||||
<View style={styles.calloutWrap}>
|
<View style={styles.calloutWrap}>
|
||||||
<Alert
|
<Alert
|
||||||
|
size="sm"
|
||||||
testID="write-failed-callout"
|
testID="write-failed-callout"
|
||||||
variant="error"
|
variant="error"
|
||||||
title={t("settings.project.writeFailures.failedTitle")}
|
title={t("settings.project.writeFailures.failedTitle")}
|
||||||
|
|||||||
@@ -226,12 +226,23 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HostConnectionError({ serverId }: { serverId: string }) {
|
function HostConnectionError({ serverId }: { serverId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||||
const lastError = snapshot?.lastError ?? null;
|
const lastError = snapshot?.lastError ?? null;
|
||||||
const connectionError =
|
const connectionError =
|
||||||
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||||
if (!connectionError) return null;
|
if (!connectionError) return null;
|
||||||
return <Text style={styles.errorText}>{connectionError}</Text>;
|
return (
|
||||||
|
<View style={styles.connectionError}>
|
||||||
|
<InlineAlert
|
||||||
|
size="sm"
|
||||||
|
variant="error"
|
||||||
|
title={connectionError}
|
||||||
|
description={snapshot?.authFailureReason ? t("settings.host.password.guidance") : undefined}
|
||||||
|
testID="host-connection-error"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||||
@@ -371,6 +382,7 @@ export function HostSettingsPage({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<HostStatusBadges serverId={serverId} />
|
<HostStatusBadges serverId={serverId} />
|
||||||
|
<HostConnectionError serverId={serverId} />
|
||||||
|
|
||||||
<HostAppearanceSection host={host} />
|
<HostAppearanceSection host={host} />
|
||||||
|
|
||||||
@@ -835,6 +847,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
|||||||
</View>
|
</View>
|
||||||
{updateState.status === "complete" ? (
|
{updateState.status === "complete" ? (
|
||||||
<InlineAlert
|
<InlineAlert
|
||||||
|
size="sm"
|
||||||
variant="success"
|
variant="success"
|
||||||
title={t("desktop.daemon.lifecycle.workerUpdated", {
|
title={t("desktop.daemon.lifecycle.workerUpdated", {
|
||||||
version: updateState.workerVersion,
|
version: updateState.workerVersion,
|
||||||
@@ -845,6 +858,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
|
|||||||
{updateState.status === "failed" ? (
|
{updateState.status === "failed" ? (
|
||||||
<View style={styles.updateFailure}>
|
<View style={styles.updateFailure}>
|
||||||
<InlineAlert
|
<InlineAlert
|
||||||
|
size="sm"
|
||||||
variant="error"
|
variant="error"
|
||||||
title={updateState.title}
|
title={updateState.title}
|
||||||
description={updateState.message}
|
description={updateState.message}
|
||||||
@@ -1742,10 +1756,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
errorText: {
|
connectionError: {
|
||||||
color: theme.colors.palette.red[300],
|
marginBottom: theme.spacing[6],
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
},
|
||||||
connectionLatency: {
|
connectionLatency: {
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ function PluginLogsSheet({
|
|||||||
if (logs.isError) {
|
if (logs.isError) {
|
||||||
content = (
|
content = (
|
||||||
<Alert
|
<Alert
|
||||||
|
size="sm"
|
||||||
variant="error"
|
variant="error"
|
||||||
title={t("settings.plugins.logs.errorTitle")}
|
title={t("settings.plugins.logs.errorTitle")}
|
||||||
description={errorMessage(logs.error)}
|
description={errorMessage(logs.error)}
|
||||||
@@ -412,6 +413,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
|||||||
if (pageState === "offline") {
|
if (pageState === "offline") {
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
|
size="md"
|
||||||
variant="warning"
|
variant="warning"
|
||||||
title={t("settings.plugins.states.offlineTitle")}
|
title={t("settings.plugins.states.offlineTitle")}
|
||||||
description={t("settings.plugins.states.offlineDescription")}
|
description={t("settings.plugins.states.offlineDescription")}
|
||||||
@@ -419,13 +421,14 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (pageState === "unsupported") {
|
if (pageState === "unsupported") {
|
||||||
return <Alert variant="warning" title={t("settings.plugins.states.updateTitle")} />;
|
return <Alert size="md" variant="warning" title={t("settings.plugins.states.updateTitle")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
let catalogContent = <Alert title={t("settings.plugins.states.loading")} />;
|
let catalogContent = <Alert size="sm" title={t("settings.plugins.states.loading")} />;
|
||||||
if (pageState === "error") {
|
if (pageState === "error") {
|
||||||
catalogContent = (
|
catalogContent = (
|
||||||
<Alert
|
<Alert
|
||||||
|
size="sm"
|
||||||
variant="error"
|
variant="error"
|
||||||
title={t("settings.plugins.states.errorTitle")}
|
title={t("settings.plugins.states.errorTitle")}
|
||||||
description={errorMessage(plugins.error)}
|
description={errorMessage(plugins.error)}
|
||||||
@@ -469,6 +472,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
|||||||
<View>
|
<View>
|
||||||
<SettingsSection title={t("settings.plugins.title")}>
|
<SettingsSection title={t("settings.plugins.title")}>
|
||||||
<Alert
|
<Alert
|
||||||
|
size="sm"
|
||||||
variant="warning"
|
variant="warning"
|
||||||
title={t("settings.plugins.trustedTitle")}
|
title={t("settings.plugins.trustedTitle")}
|
||||||
description={t("settings.plugins.trustedDescription")}
|
description={t("settings.plugins.trustedDescription")}
|
||||||
@@ -508,10 +512,15 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Alert variant="warning" title={t("settings.plugins.states.sourceUpdateTitle")} />
|
<Alert
|
||||||
|
size="sm"
|
||||||
|
variant="warning"
|
||||||
|
title={t("settings.plugins.states.sourceUpdateTitle")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{feedback ? (
|
{feedback ? (
|
||||||
<Alert
|
<Alert
|
||||||
|
size="sm"
|
||||||
variant={feedback.kind}
|
variant={feedback.kind}
|
||||||
title={feedback.message}
|
title={feedback.message}
|
||||||
testID="plugin-management-feedback"
|
testID="plugin-management-feedback"
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ describe("normalizeStoredHostProfile", () => {
|
|||||||
expect(profile?.connections[0]).not.toHaveProperty("password");
|
expect(profile?.connections[0]).not.toHaveProperty("password");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves a stored direct TCP password to the host profile", () => {
|
||||||
|
const profile = normalizeStoredHostProfile({
|
||||||
|
serverId: "srv_legacy",
|
||||||
|
connections: [{ type: "directTcp", endpoint: "localhost:6767", password: "old-secret" }],
|
||||||
|
});
|
||||||
|
expect(profile?.password).toBe("old-secret");
|
||||||
|
expect(profile?.connections[0]).not.toHaveProperty("password");
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves legacy relay ids when TLS is absent", () => {
|
it("preserves legacy relay ids when TLS is absent", () => {
|
||||||
const profile = normalizeStoredHostProfile({
|
const profile = normalizeStoredHostProfile({
|
||||||
serverId: "srv_relay",
|
serverId: "srv_relay",
|
||||||
@@ -228,26 +237,28 @@ describe("upsertHostConnectionInProfiles", () => {
|
|||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint: "example.test:6767",
|
endpoint: "example.test:6767",
|
||||||
useTls: false,
|
useTls: false,
|
||||||
password: "old-secret",
|
|
||||||
};
|
};
|
||||||
const existing: HostProfile = {
|
const existing: HostProfile = {
|
||||||
...makeHost("srv_known"),
|
...makeHost("srv_known"),
|
||||||
|
password: "old-secret",
|
||||||
connections: [existingConnection],
|
connections: [existingConnection],
|
||||||
preferredConnectionId: existingConnection.id,
|
preferredConnectionId: existingConnection.id,
|
||||||
};
|
};
|
||||||
const replacement: HostConnection = {
|
const replacement: HostConnection = {
|
||||||
...existingConnection,
|
...existingConnection,
|
||||||
useTls: true,
|
useTls: true,
|
||||||
password: "new-secret",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [profile] = upsertHostConnectionInProfiles({
|
const [profile] = upsertHostConnectionInProfiles({
|
||||||
profiles: [existing],
|
profiles: [existing],
|
||||||
serverId: "srv_known",
|
serverId: "srv_known",
|
||||||
connection: replacement,
|
connection: replacement,
|
||||||
|
password: "new-secret",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(profile.connections).toEqual([replacement]);
|
expect(profile.connections).toEqual([replacement]);
|
||||||
|
expect("password" in profile.connections[0]!).toBe(false);
|
||||||
|
expect(profile.password).toBe("new-secret");
|
||||||
expect(profile.preferredConnectionId).toBe(replacement.id);
|
expect(profile.preferredConnectionId).toBe(replacement.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
} from "@getpaseo/protocol/daemon-endpoints";
|
} from "@getpaseo/protocol/daemon-endpoints";
|
||||||
import {
|
import {
|
||||||
DirectTcpHostConnectionSchema,
|
DirectTcpHostConnectionSchema,
|
||||||
type DirectTcpHostConnection,
|
type DirectTcpHostConnection as WireDirectTcpHostConnection,
|
||||||
} from "@getpaseo/protocol/host-connection-schema";
|
} from "@getpaseo/protocol/host-connection-schema";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SSH_DAEMON_PORT,
|
DEFAULT_SSH_DAEMON_PORT,
|
||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
} from "@/hosts/appearance";
|
} from "@/hosts/appearance";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
|
export { DirectTcpHostConnectionSchema };
|
||||||
|
export type DirectTcpHostConnection = Omit<WireDirectTcpHostConnection, "password">;
|
||||||
|
|
||||||
export interface DirectSocketHostConnection {
|
export interface DirectSocketHostConnection {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,6 +60,7 @@ export type HostLifecycle = Record<string, never>;
|
|||||||
|
|
||||||
export interface HostProfile {
|
export interface HostProfile {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
password?: string;
|
||||||
label: string;
|
label: string;
|
||||||
appearance: HostAppearance;
|
appearance: HostAppearance;
|
||||||
lifecycle: HostLifecycle;
|
lifecycle: HostLifecycle;
|
||||||
@@ -125,11 +127,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (left.type === "directTcp" && right.type === "directTcp") {
|
if (left.type === "directTcp" && right.type === "directTcp") {
|
||||||
return (
|
return left.endpoint === right.endpoint && (left.useTls ?? false) === (right.useTls ?? false);
|
||||||
left.endpoint === right.endpoint &&
|
|
||||||
(left.useTls ?? false) === (right.useTls ?? false) &&
|
|
||||||
left.password === right.password
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (left.type === "directSocket" && right.type === "directSocket") {
|
if (left.type === "directSocket" && right.type === "directSocket") {
|
||||||
return left.path === right.path;
|
return left.path === right.path;
|
||||||
@@ -186,11 +184,40 @@ function upsertHostConnectionById(
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasProfileChanged(input: {
|
||||||
|
matchingCount: number;
|
||||||
|
previous: HostProfile;
|
||||||
|
serverId: string;
|
||||||
|
password?: string;
|
||||||
|
createdAt: string;
|
||||||
|
label: string;
|
||||||
|
preferredConnectionId: string;
|
||||||
|
lifecycle: HostLifecycle;
|
||||||
|
connections: HostConnection[];
|
||||||
|
}): boolean {
|
||||||
|
const { previous, connections } = input;
|
||||||
|
return (
|
||||||
|
input.matchingCount > 1 ||
|
||||||
|
previous.serverId !== input.serverId ||
|
||||||
|
(input.password !== undefined && input.password !== previous.password) ||
|
||||||
|
input.createdAt !== previous.createdAt ||
|
||||||
|
input.label !== previous.label ||
|
||||||
|
input.preferredConnectionId !== previous.preferredConnectionId ||
|
||||||
|
!hostLifecycleEquals(previous.lifecycle, input.lifecycle) ||
|
||||||
|
connections.length !== previous.connections.length ||
|
||||||
|
connections.some((candidate, index) => {
|
||||||
|
const old = previous.connections[index];
|
||||||
|
return !old || !hostConnectionEquals(candidate, old);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function upsertHostConnectionInProfiles(input: {
|
export function upsertHostConnectionInProfiles(input: {
|
||||||
profiles: HostProfile[];
|
profiles: HostProfile[];
|
||||||
serverId: string;
|
serverId: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
connection: HostConnection;
|
connection: HostConnection;
|
||||||
|
password?: string;
|
||||||
now?: string;
|
now?: string;
|
||||||
}): HostProfile[] {
|
}): HostProfile[] {
|
||||||
const serverId = input.serverId.trim();
|
const serverId = input.serverId.trim();
|
||||||
@@ -199,13 +226,17 @@ export function upsertHostConnectionInProfiles(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = input.now ?? new Date().toISOString();
|
const now = input.now ?? new Date().toISOString();
|
||||||
|
const password = input.password;
|
||||||
|
const normalizedConnection = input.connection;
|
||||||
const labelTrimmed = input.label?.trim() ?? "";
|
const labelTrimmed = input.label?.trim() ?? "";
|
||||||
const derivedLabel = labelTrimmed || serverId;
|
const derivedLabel = labelTrimmed || serverId;
|
||||||
const existing = input.profiles;
|
const existing = input.profiles;
|
||||||
const matchingIndexes = existing.reduce<number[]>((matches, daemon, index) => {
|
const matchingIndexes = existing.reduce<number[]>((matches, daemon, index) => {
|
||||||
if (
|
if (
|
||||||
daemon.serverId === serverId ||
|
daemon.serverId === serverId ||
|
||||||
daemon.connections.some((connection) => hostConnectionEquals(connection, input.connection))
|
daemon.connections.some((existingConnection) =>
|
||||||
|
hostConnectionEquals(existingConnection, normalizedConnection),
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
matches.push(index);
|
matches.push(index);
|
||||||
}
|
}
|
||||||
@@ -215,11 +246,12 @@ export function upsertHostConnectionInProfiles(input: {
|
|||||||
if (matchingIndexes.length === 0) {
|
if (matchingIndexes.length === 0) {
|
||||||
const profile: HostProfile = {
|
const profile: HostProfile = {
|
||||||
serverId,
|
serverId,
|
||||||
|
...(password ? { password } : {}),
|
||||||
label: derivedLabel,
|
label: derivedLabel,
|
||||||
appearance: defaultHostAppearance(),
|
appearance: defaultHostAppearance(),
|
||||||
lifecycle: defaultLifecycle(),
|
lifecycle: defaultLifecycle(),
|
||||||
connections: [input.connection],
|
connections: [normalizedConnection],
|
||||||
preferredConnectionId: input.connection.id,
|
preferredConnectionId: normalizedConnection.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
@@ -230,7 +262,7 @@ export function upsertHostConnectionInProfiles(input: {
|
|||||||
const prev = matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0];
|
const prev = matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0];
|
||||||
const nextConnections = upsertHostConnectionById(
|
const nextConnections = upsertHostConnectionById(
|
||||||
matchedProfiles.flatMap((daemon) => daemon.connections),
|
matchedProfiles.flatMap((daemon) => daemon.connections),
|
||||||
input.connection,
|
normalizedConnection,
|
||||||
);
|
);
|
||||||
const nextLifecycle = prev.lifecycle;
|
const nextLifecycle = prev.lifecycle;
|
||||||
const nextLabel = prev.label === prev.serverId ? derivedLabel : prev.label;
|
const nextLabel = prev.label === prev.serverId ? derivedLabel : prev.label;
|
||||||
@@ -238,23 +270,22 @@ export function upsertHostConnectionInProfiles(input: {
|
|||||||
prev.preferredConnectionId &&
|
prev.preferredConnectionId &&
|
||||||
nextConnections.some((connection) => connection.id === prev.preferredConnectionId)
|
nextConnections.some((connection) => connection.id === prev.preferredConnectionId)
|
||||||
? prev.preferredConnectionId
|
? prev.preferredConnectionId
|
||||||
: input.connection.id;
|
: normalizedConnection.id;
|
||||||
const nextCreatedAt = matchedProfiles.reduce(
|
const nextCreatedAt = matchedProfiles.reduce(
|
||||||
(earliest, daemon) => (daemon.createdAt < earliest ? daemon.createdAt : earliest),
|
(earliest, daemon) => (daemon.createdAt < earliest ? daemon.createdAt : earliest),
|
||||||
prev.createdAt,
|
prev.createdAt,
|
||||||
);
|
);
|
||||||
const changed =
|
const changed = hasProfileChanged({
|
||||||
matchingIndexes.length > 1 ||
|
matchingCount: matchingIndexes.length,
|
||||||
prev.serverId !== serverId ||
|
previous: prev,
|
||||||
nextCreatedAt !== prev.createdAt ||
|
serverId,
|
||||||
nextLabel !== prev.label ||
|
password,
|
||||||
nextPreferredConnectionId !== prev.preferredConnectionId ||
|
createdAt: nextCreatedAt,
|
||||||
!hostLifecycleEquals(prev.lifecycle, nextLifecycle) ||
|
label: nextLabel,
|
||||||
nextConnections.length !== prev.connections.length ||
|
preferredConnectionId: nextPreferredConnectionId,
|
||||||
nextConnections.some((connection, index) => {
|
lifecycle: nextLifecycle,
|
||||||
const previousConnection = prev.connections[index];
|
connections: nextConnections,
|
||||||
return !previousConnection || !hostConnectionEquals(connection, previousConnection);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
return existing;
|
return existing;
|
||||||
@@ -262,6 +293,7 @@ export function upsertHostConnectionInProfiles(input: {
|
|||||||
|
|
||||||
const nextProfile: HostProfile = {
|
const nextProfile: HostProfile = {
|
||||||
...prev,
|
...prev,
|
||||||
|
...(password ? { password } : {}),
|
||||||
serverId,
|
serverId,
|
||||||
label: nextLabel,
|
label: nextLabel,
|
||||||
lifecycle: nextLifecycle,
|
lifecycle: nextLifecycle,
|
||||||
@@ -386,6 +418,7 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [
|
|||||||
]);
|
]);
|
||||||
const StoredHostProfileSchema = z.strictObject({
|
const StoredHostProfileSchema = z.strictObject({
|
||||||
serverId: z.string().trim().min(1),
|
serverId: z.string().trim().min(1),
|
||||||
|
password: z.string().optional(),
|
||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
appearance: HostAppearanceSchema.optional(),
|
appearance: HostAppearanceSchema.optional(),
|
||||||
lifecycle: z.strictObject({}).optional(),
|
lifecycle: z.strictObject({}).optional(),
|
||||||
@@ -401,13 +434,13 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec
|
|||||||
if (connection.type === "directTcp") {
|
if (connection.type === "directTcp") {
|
||||||
try {
|
try {
|
||||||
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint));
|
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint));
|
||||||
return DirectTcpHostConnectionSchema.parse({
|
const parsed = DirectTcpHostConnectionSchema.parse({
|
||||||
id: `direct:${endpoint}`,
|
id: `direct:${endpoint}`,
|
||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint,
|
endpoint,
|
||||||
useTls: connection.useTls,
|
useTls: connection.useTls,
|
||||||
...(connection.password !== undefined ? { password: connection.password } : {}),
|
|
||||||
});
|
});
|
||||||
|
return { id: parsed.id, type: parsed.type, endpoint: parsed.endpoint, useTls: parsed.useTls };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -459,6 +492,12 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
|
|||||||
}
|
}
|
||||||
const record = result.data;
|
const record = result.data;
|
||||||
const serverId = record.serverId;
|
const serverId = record.serverId;
|
||||||
|
// COMPAT(connectionPassword): added in v0.9.1, remove after 2027-03-24 once stored direct passwords have migrated.
|
||||||
|
const legacyPassword = record.connections.find(
|
||||||
|
(connection) => connection.type === "directTcp" && connection.password,
|
||||||
|
);
|
||||||
|
const password =
|
||||||
|
record.password ?? (legacyPassword?.type === "directTcp" ? legacyPassword.password : undefined);
|
||||||
|
|
||||||
const connections = record.connections
|
const connections = record.connections
|
||||||
.map((connection) => normalizeStoredConnection(connection))
|
.map((connection) => normalizeStoredConnection(connection))
|
||||||
@@ -478,6 +517,7 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
|
...(password ? { password } : {}),
|
||||||
label,
|
label,
|
||||||
appearance: record.appearance ?? defaultHostAppearance(),
|
appearance: record.appearance ?? defaultHostAppearance(),
|
||||||
lifecycle: defaultLifecycle(),
|
lifecycle: defaultLifecycle(),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
extractHostPortFromWebSocketUrl,
|
extractHostPortFromWebSocketUrl,
|
||||||
normalizeHostPort,
|
normalizeHostPort,
|
||||||
parseConnectionUri,
|
parseConnectionUri,
|
||||||
|
parseRelayConnectionUri,
|
||||||
|
serializeRelayConnectionUri,
|
||||||
parseHostPort,
|
parseHostPort,
|
||||||
serializeConnectionUri,
|
serializeConnectionUri,
|
||||||
serializeConnectionUriForStorage,
|
serializeConnectionUriForStorage,
|
||||||
@@ -22,6 +24,8 @@ export {
|
|||||||
extractHostPortFromWebSocketUrl,
|
extractHostPortFromWebSocketUrl,
|
||||||
normalizeHostPort,
|
normalizeHostPort,
|
||||||
parseConnectionUri,
|
parseConnectionUri,
|
||||||
|
parseRelayConnectionUri,
|
||||||
|
serializeRelayConnectionUri,
|
||||||
parseHostPort,
|
parseHostPort,
|
||||||
serializeConnectionUri,
|
serializeConnectionUri,
|
||||||
serializeConnectionUriForStorage,
|
serializeConnectionUriForStorage,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
|
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
|
||||||
|
|
||||||
class FakeDaemonClient implements DaemonProbeClient {
|
class FakeDaemonClient implements DaemonProbeClient {
|
||||||
@@ -169,16 +170,15 @@ describe("test-daemon-connection connectToDaemon", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes direct TCP connection passwords into the client config", async () => {
|
it("passes the host password into the client config", async () => {
|
||||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||||
const result = await connectToDaemon(
|
const result = await connectToDaemon(
|
||||||
{
|
{
|
||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
password: "shared-secret",
|
|
||||||
},
|
},
|
||||||
undefined,
|
{ password: "shared-secret" },
|
||||||
probe.deps,
|
probe.deps,
|
||||||
);
|
);
|
||||||
await result.client.close();
|
await result.client.close();
|
||||||
@@ -242,7 +242,7 @@ describe("test-daemon-connection connectToDaemon", () => {
|
|||||||
it("surfaces auth rejection as an incorrect password", async () => {
|
it("surfaces auth rejection as an incorrect password", async () => {
|
||||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||||
probe.failNextConnection(
|
probe.failNextConnection(
|
||||||
new Error("Transport closed (code 4001)"),
|
new DaemonAuthenticationError("incorrect_password"),
|
||||||
"Transport closed (code 4001)",
|
"Transport closed (code 4001)",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -252,13 +252,13 @@ describe("test-daemon-connection connectToDaemon", () => {
|
|||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
password: "wrong-secret",
|
|
||||||
},
|
},
|
||||||
undefined,
|
{ password: "wrong-secret" },
|
||||||
probe.deps,
|
probe.deps,
|
||||||
),
|
),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
message: "Incorrect password",
|
message: "Incorrect password",
|
||||||
|
authFailureReason: "incorrect_password",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,9 +272,8 @@ describe("test-daemon-connection connectToDaemon", () => {
|
|||||||
id: "direct:lan:6767",
|
id: "direct:lan:6767",
|
||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint: "lan:6767",
|
endpoint: "lan:6767",
|
||||||
password: "shared-secret",
|
|
||||||
},
|
},
|
||||||
undefined,
|
{ password: "shared-secret" },
|
||||||
probe.deps,
|
probe.deps,
|
||||||
),
|
),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import {
|
||||||
|
DaemonAuthenticationError,
|
||||||
|
DaemonClient,
|
||||||
|
getDaemonAuthFailureReason,
|
||||||
|
type DaemonAuthFailureReason,
|
||||||
|
} from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { HostConnection } from "@/types/host-connection";
|
import type { HostConnection } from "@/types/host-connection";
|
||||||
import { getOrCreateClientId } from "./client-id";
|
import { getOrCreateClientId } from "./client-id";
|
||||||
@@ -16,6 +21,7 @@ import type { DesktopDaemonTransportTarget } from "@/desktop/daemon/desktop-daem
|
|||||||
|
|
||||||
export interface DaemonProbeClient {
|
export interface DaemonProbeClient {
|
||||||
readonly lastError: string | null;
|
readonly lastError: string | null;
|
||||||
|
readonly authFailureReason?: DaemonAuthFailureReason | null;
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
|
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
|
||||||
@@ -84,41 +90,53 @@ function pickBestReason(reason: string | null, lastError: string | null): string
|
|||||||
return "Unable to connect";
|
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 {
|
export class DaemonConnectionTestError extends Error {
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
|
authFailureReason: DaemonAuthFailureReason | null;
|
||||||
|
|
||||||
constructor(message: string, details: { reason: string | null; lastError: string | null }) {
|
constructor(
|
||||||
|
message: string,
|
||||||
|
details: {
|
||||||
|
reason: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
authFailureReason?: DaemonAuthFailureReason | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "DaemonConnectionTestError";
|
this.name = "DaemonConnectionTestError";
|
||||||
this.reason = details.reason;
|
this.reason = details.reason;
|
||||||
this.lastError = details.lastError;
|
this.lastError = details.lastError;
|
||||||
|
this.authFailureReason = details.authFailureReason ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getConnectionAuthFailureReason(error: unknown): DaemonAuthFailureReason | null {
|
||||||
|
if (error instanceof DaemonConnectionTestError) return error.authFailureReason;
|
||||||
|
return getDaemonAuthFailureReason(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConnectionCredentials(
|
||||||
|
connection: HostConnection,
|
||||||
|
options:
|
||||||
|
| { password?: string; localCredential?: DaemonClientConfig["localCredential"] }
|
||||||
|
| undefined,
|
||||||
|
): Pick<DaemonClientConfig, "password" | "localCredential"> {
|
||||||
|
const password = options?.password;
|
||||||
|
return {
|
||||||
|
...(password ? { password } : {}),
|
||||||
|
...(options?.localCredential ? { localCredential: options.localCredential } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildClientConfig(
|
export async function buildClientConfig(
|
||||||
connection: HostConnection,
|
connection: HostConnection,
|
||||||
serverId?: string,
|
serverId?: string,
|
||||||
options?: {
|
options?: {
|
||||||
capabilities?: DaemonClientConfig["capabilities"];
|
capabilities?: DaemonClientConfig["capabilities"];
|
||||||
trace?: DaemonClientConfig["trace"];
|
trace?: DaemonClientConfig["trace"];
|
||||||
|
password?: string;
|
||||||
|
localCredential?: DaemonClientConfig["localCredential"];
|
||||||
},
|
},
|
||||||
deps: Pick<
|
deps: Pick<
|
||||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||||
@@ -136,6 +154,7 @@ export async function buildClientConfig(
|
|||||||
appVersion: deps.resolveAppVersion() ?? undefined,
|
appVersion: deps.resolveAppVersion() ?? undefined,
|
||||||
suppressSendErrors: true,
|
suppressSendErrors: true,
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
|
...resolveConnectionCredentials(connection, options),
|
||||||
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
|
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
|
||||||
...(options?.trace ? { trace: options.trace } : {}),
|
...(options?.trace ? { trace: options.trace } : {}),
|
||||||
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
||||||
@@ -167,7 +186,6 @@ export async function buildClientConfig(
|
|||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
|
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
|
||||||
...(connection.password ? { password: connection.password } : {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,11 +263,13 @@ export function connectAndProbe(
|
|||||||
error instanceof Error ? error.message : String(error),
|
error instanceof Error ? error.message : String(error),
|
||||||
);
|
);
|
||||||
const lastError = normalizeNonEmptyString(client.lastError);
|
const lastError = normalizeNonEmptyString(client.lastError);
|
||||||
const message = isIncorrectPasswordFailure({ config, reason, lastError })
|
const authFailureReason =
|
||||||
? "Incorrect password"
|
getDaemonAuthFailureReason(error) ?? client.authFailureReason ?? null;
|
||||||
|
const message = authFailureReason
|
||||||
|
? new DaemonAuthenticationError(authFailureReason).message
|
||||||
: pickBestReason(reason, lastError);
|
: pickBestReason(reason, lastError);
|
||||||
void client.close().catch(() => undefined);
|
void client.close().catch(() => undefined);
|
||||||
reject(new DaemonConnectionTestError(message, { reason, lastError }));
|
reject(new DaemonConnectionTestError(message, { reason, lastError, authFailureReason }));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -258,6 +278,8 @@ export function connectAndProbe(
|
|||||||
interface ProbeOptions {
|
interface ProbeOptions {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
password?: string;
|
||||||
|
localCredential?: DaemonClientConfig["localCredential"];
|
||||||
capabilities?: DaemonClientConfig["capabilities"];
|
capabilities?: DaemonClientConfig["capabilities"];
|
||||||
trace?: DaemonClientConfig["trace"];
|
trace?: DaemonClientConfig["trace"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ test("managed two-home restart retains its supervisor and never routes ordinary
|
|||||||
const beforeA = await f.liveStatus(a);
|
const beforeA = await f.liveStatus(a);
|
||||||
const beforeB = await f.liveStatus(b, poisoned);
|
const beforeB = await f.liveStatus(b, poisoned);
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
for (const home of [a, b, path.join(f.root, ".paseo")])
|
for (const home of [a, b]) expect((await stat(home)).mode & 0o777).toBe(0o700);
|
||||||
expect((await stat(home)).mode & 0o777).toBe(0o700);
|
expect(existsSync(path.join(f.root, ".paseo"))).toBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const repoB = path.join(f.root, "project-b");
|
const repoB = path.join(f.root, "project-b");
|
||||||
@@ -197,7 +197,7 @@ test("removed flags and ambiguous targets fail before side effects; observation
|
|||||||
}
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
test("status reports the server id of a password-protected daemon to a caller without the password", async () => {
|
test("local status uses the credential and reports the server id of a password-protected daemon", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
const home = f.homes[0]!;
|
const home = f.homes[0]!;
|
||||||
try {
|
try {
|
||||||
@@ -209,7 +209,7 @@ test("status reports the server id of a password-protected daemon to a caller wi
|
|||||||
await f.ok(["start", "--home", home, "--timeout", "30"]);
|
await f.ok(["start", "--home", home, "--timeout", "30"]);
|
||||||
const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" });
|
const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" });
|
||||||
expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({
|
expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({
|
||||||
connectedDaemon: "auth_required",
|
connectedDaemon: "reachable",
|
||||||
serverId: authenticated.serverId,
|
serverId: authenticated.serverId,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { connectToDaemon } from "../../utils/client.js";
|
|||||||
import type { DaemonTarget } from "../../utils/daemon-target.js";
|
import type { DaemonTarget } from "../../utils/daemon-target.js";
|
||||||
import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js";
|
import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js";
|
||||||
import { formatPairingInstructions } from "../../output/pairing.js";
|
import { formatPairingInstructions } from "../../output/pairing.js";
|
||||||
|
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||||
|
import { serializeRelayConnectionUri } from "@getpaseo/protocol/daemon-endpoints";
|
||||||
|
|
||||||
interface PairOptions {
|
interface PairOptions {
|
||||||
daemonTarget: DaemonTarget;
|
daemonTarget: DaemonTarget;
|
||||||
@@ -195,10 +197,13 @@ function outputPairingResult(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const offer = parseConnectionOfferFromUrl(pairing.url);
|
||||||
|
const connectionUri = offer ? serializeRelayConnectionUri({ offer }) : null;
|
||||||
|
|
||||||
if (options.json) {
|
if (options.json) {
|
||||||
output.writeStdout(
|
output.writeStdout(
|
||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
{ relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr },
|
{ relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr, connectionUri },
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
)}\n`,
|
)}\n`,
|
||||||
@@ -210,6 +215,7 @@ function outputPairingResult(
|
|||||||
formatPairingInstructions({
|
formatPairingInstructions({
|
||||||
url: pairing.url,
|
url: pairing.url,
|
||||||
qr: pairing.qr,
|
qr: pairing.qr,
|
||||||
|
connectionUri,
|
||||||
columns: output.columns,
|
columns: output.columns,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,4 +30,17 @@ describe("formatPairingInstructions", () => {
|
|||||||
expect(output).toContain("terminal width could not be detected");
|
expect(output).toContain("terminal width could not be detected");
|
||||||
expect(output.split("\n")).toContain(URL);
|
expect(output.split("\n")).toContain(URL);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("redacts a password in a printed connection URI", () => {
|
||||||
|
const output = formatPairingInstructions({
|
||||||
|
qr: null,
|
||||||
|
url: URL,
|
||||||
|
connectionUri: "relay://relay.paseo.sh:443/srv_test?key=public&password=private-value",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toContain(
|
||||||
|
"relay://relay.paseo.sh:443/srv_test?key=public&password=%5Bredacted%5D",
|
||||||
|
);
|
||||||
|
expect(output).not.toContain("private-value");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
|
|||||||
interface PairingInstructions {
|
interface PairingInstructions {
|
||||||
url: string;
|
url: string;
|
||||||
qr: string | null;
|
qr: string | null;
|
||||||
|
connectionUri?: string | null;
|
||||||
columns?: number;
|
columns?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,23 @@ function formatQr(qr: string | null, columns: number | undefined): string {
|
|||||||
return qr;
|
return qr;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string {
|
function redactConnectionUri(uri: string): string {
|
||||||
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`;
|
try {
|
||||||
|
const parsed = new URL(uri);
|
||||||
|
if (parsed.searchParams.has("password")) {
|
||||||
|
parsed.searchParams.set("password", "[redacted]");
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return "Invalid connection URI";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPairingInstructions({
|
||||||
|
url,
|
||||||
|
qr,
|
||||||
|
columns,
|
||||||
|
connectionUri,
|
||||||
|
}: PairingInstructions): string {
|
||||||
|
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}${connectionUri ? `\n\nConnection URI:\n${redactConnectionUri(connectionUri)}` : ""}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
waitForDaemonReady,
|
waitForDaemonReady,
|
||||||
resolvePaseoHome,
|
resolvePaseoHome,
|
||||||
type DaemonInstance,
|
type DaemonInstance,
|
||||||
|
readLocalCredentialForTarget,
|
||||||
} from "@getpaseo/server/daemon-control";
|
} from "@getpaseo/server/daemon-control";
|
||||||
import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js";
|
import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js";
|
||||||
export type { DaemonTarget } from "./daemon-target.js";
|
export type { DaemonTarget } from "./daemon-target.js";
|
||||||
@@ -28,6 +29,12 @@ export interface ConnectOptions {
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
instance?: DaemonInstance;
|
instance?: DaemonInstance;
|
||||||
}
|
}
|
||||||
|
export function resolveClientPaseoHome(
|
||||||
|
target: DaemonTarget,
|
||||||
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||||
|
): string {
|
||||||
|
return target.kind === "instance" ? target.home : resolvePaseoHome(env);
|
||||||
|
}
|
||||||
const DEFAULT_TIMEOUT = 15000;
|
const DEFAULT_TIMEOUT = 15000;
|
||||||
type TransportTarget =
|
type TransportTarget =
|
||||||
| { type: "tcp"; url: string }
|
| { type: "tcp"; url: string }
|
||||||
@@ -172,6 +179,16 @@ export function resolveDaemonPassword(host: string): string | undefined {
|
|||||||
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
|
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveDaemonCredential(
|
||||||
|
host: string,
|
||||||
|
home: string,
|
||||||
|
): { kind: "password"; password: string } | { kind: "localCredential"; token: string } | null {
|
||||||
|
const password = resolveDaemonPassword(host);
|
||||||
|
if (password) return { kind: "password", password };
|
||||||
|
const token = readLocalCredentialForTarget(home, host);
|
||||||
|
return token ? { kind: "localCredential", token } : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a WebSocket factory that works in Node.js
|
* Create a WebSocket factory that works in Node.js
|
||||||
*/
|
*/
|
||||||
@@ -193,7 +210,8 @@ function createNodeWebSocketFactory() {
|
|||||||
*/
|
*/
|
||||||
async function tryConnectHost(
|
async function tryConnectHost(
|
||||||
host: string,
|
host: string,
|
||||||
password: string | undefined,
|
credential: ReturnType<typeof resolveDaemonCredential>,
|
||||||
|
home: string,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
timeout: number,
|
timeout: number,
|
||||||
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
|
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
|
||||||
@@ -204,7 +222,10 @@ async function tryConnectHost(
|
|||||||
clientId,
|
clientId,
|
||||||
clientType: "cli",
|
clientType: "cli",
|
||||||
appVersion: resolveCliVersion(),
|
appVersion: resolveCliVersion(),
|
||||||
password,
|
...(credential?.kind === "password" ? { password: credential.password } : {}),
|
||||||
|
...(credential?.kind === "localCredential"
|
||||||
|
? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined }
|
||||||
|
: {}),
|
||||||
connectTimeoutMs: timeout,
|
connectTimeoutMs: timeout,
|
||||||
webSocketFactory: (
|
webSocketFactory: (
|
||||||
url: string,
|
url: string,
|
||||||
@@ -287,7 +308,8 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
|
|||||||
instance: options.instance,
|
instance: options.instance,
|
||||||
})
|
})
|
||||||
).listen;
|
).listen;
|
||||||
const clientId = await getOrCreateCliClientId(resolvePaseoHome({}));
|
const home = resolveClientPaseoHome(options.target);
|
||||||
|
const clientId = await getOrCreateCliClientId(home);
|
||||||
const nodeWebSocketFactory = createNodeWebSocketFactory();
|
const nodeWebSocketFactory = createNodeWebSocketFactory();
|
||||||
|
|
||||||
if (explicitHost?.trim().startsWith("ssh://")) {
|
if (explicitHost?.trim().startsWith("ssh://")) {
|
||||||
@@ -296,7 +318,8 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
|
|||||||
const password = resolveDaemonPassword(explicitHost);
|
const password = resolveDaemonPassword(explicitHost);
|
||||||
const result = await tryConnectHost(
|
const result = await tryConnectHost(
|
||||||
tunnel.endpoint,
|
tunnel.endpoint,
|
||||||
password,
|
password ? { kind: "password", password } : null,
|
||||||
|
home,
|
||||||
clientId,
|
clientId,
|
||||||
Math.max(1, deadline - Date.now()),
|
Math.max(1, deadline - Date.now()),
|
||||||
nodeWebSocketFactory,
|
nodeWebSocketFactory,
|
||||||
@@ -330,7 +353,8 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
|
|||||||
|
|
||||||
const result = await tryConnectHost(
|
const result = await tryConnectHost(
|
||||||
explicitHost,
|
explicitHost,
|
||||||
resolveDaemonPassword(explicitHost),
|
resolveDaemonCredential(explicitHost, home),
|
||||||
|
home,
|
||||||
clientId,
|
clientId,
|
||||||
Math.max(1, deadline - Date.now()),
|
Math.max(1, deadline - Date.now()),
|
||||||
nodeWebSocketFactory,
|
nodeWebSocketFactory,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { test, expect } from "vitest";
|
import { test, expect } from "vitest";
|
||||||
import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js";
|
import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js";
|
||||||
|
import { resolveDaemonCredential, resolveClientPaseoHome } from "./client.js";
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
test("explicit selectors win over both environment selectors", () => {
|
test("explicit selectors win over both environment selectors", () => {
|
||||||
const env = { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" };
|
const env = { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" };
|
||||||
@@ -12,6 +16,15 @@ test("explicit selectors win over both environment selectors", () => {
|
|||||||
expect(() => selectDaemonTarget({ home: "/tmp/b", host: "chosen:23456" }, {})).toThrow();
|
expect(() => selectDaemonTarget({ home: "/tmp/b", host: "chosen:23456" }, {})).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("endpoint connections resolve local credentials from PASEO_HOME", () => {
|
||||||
|
expect(
|
||||||
|
resolveClientPaseoHome(
|
||||||
|
{ kind: "endpoint", host: "localhost:6767" },
|
||||||
|
{ PASEO_HOME: "/tmp/custom-home" },
|
||||||
|
),
|
||||||
|
).toBe("/tmp/custom-home");
|
||||||
|
});
|
||||||
|
|
||||||
test("local operations ignore routing environment but reject an explicit endpoint", () => {
|
test("local operations ignore routing environment but reject an explicit endpoint", () => {
|
||||||
expect(
|
expect(
|
||||||
selectDaemonTarget({}, { PASEO_HOME: "/tmp/b", PASEO_HOST: "unused:12345" }, true),
|
selectDaemonTarget({}, { PASEO_HOME: "/tmp/b", PASEO_HOST: "unused:12345" }, true),
|
||||||
@@ -30,3 +43,27 @@ test("endpoint descriptions redact pairing material and credentials", () => {
|
|||||||
describeDaemonTarget({ kind: "endpoint", host: "https://app.paseo.sh/#offer=private" }),
|
describeDaemonTarget({ kind: "endpoint", host: "https://app.paseo.sh/#offer=private" }),
|
||||||
).not.toContain("private");
|
).not.toContain("private");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("CLI selects an explicit password before a matching local credential and never sends the latter remotely", async () => {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), "paseo-cli-credential-"));
|
||||||
|
const previousPassword = process.env.PASEO_PASSWORD;
|
||||||
|
delete process.env.PASEO_PASSWORD;
|
||||||
|
try {
|
||||||
|
const token = "a".repeat(43);
|
||||||
|
await writeFile(join(home, "paseo.pid"), JSON.stringify({ listen: "127.0.0.1:6767" }));
|
||||||
|
await writeFile(join(home, "local-credential"), token);
|
||||||
|
expect(resolveDaemonCredential("tcp://localhost:6767", home)).toEqual({
|
||||||
|
kind: "localCredential",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
expect(resolveDaemonCredential("tcp://remote.example:6767", home)).toBeNull();
|
||||||
|
expect(resolveDaemonCredential("tcp://localhost:6767?password=explicit", home)).toEqual({
|
||||||
|
kind: "password",
|
||||||
|
password: "explicit",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (previousPassword === undefined) delete process.env.PASEO_PASSWORD;
|
||||||
|
else process.env.PASEO_PASSWORD = previousPassword;
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { isBearerTokenValid } from "@getpaseo/server/auth";
|
import { isBearerTokenValidAsync } from "@getpaseo/server/auth";
|
||||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||||
import {
|
import {
|
||||||
runSetPasswordCommand,
|
runSetPasswordCommand,
|
||||||
@@ -61,7 +61,10 @@ try {
|
|||||||
assert.notStrictEqual(config.daemon.auth.password, "shared-secret");
|
assert.notStrictEqual(config.daemon.auth.password, "shared-secret");
|
||||||
assert.match(config.daemon.auth.password, /^\$2[aby]\$12\$/);
|
assert.match(config.daemon.auth.password, /^\$2[aby]\$12\$/);
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
isBearerTokenValid({ password: config.daemon.auth.password, token: "shared-secret" }),
|
await isBearerTokenValidAsync({
|
||||||
|
password: config.daemon.auth.password,
|
||||||
|
token: "shared-secret",
|
||||||
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
console.log("✓ set-password writes bcrypt hash without clobbering config\n");
|
console.log("✓ set-password writes bcrypt hash without clobbering config\n");
|
||||||
@@ -81,7 +84,7 @@ try {
|
|||||||
|
|
||||||
assert.strictEqual(result.data.action, "password_set");
|
assert.strictEqual(result.data.action, "password_set");
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
isBearerTokenValid({ password: config.daemon.auth.password, token: "new-secret" }),
|
await isBearerTokenValidAsync({ password: config.daemon.auth.password, token: "new-secret" }),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
console.log("✓ command accepts matching confirmation\n");
|
console.log("✓ command accepts matching confirmation\n");
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const daemon = await startTestDaemon({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
{
|
{
|
||||||
console.log("Test 1: status reports password requirement without marking daemon unreachable");
|
console.log("Test 1: local status uses the daemon's local credential");
|
||||||
const result = await runLocalPaseo(["daemon", "status", "--json"], {
|
const result = await runLocalPaseo(["daemon", "status", "--json"], {
|
||||||
PASEO_HOME: daemon.paseoHome,
|
PASEO_HOME: daemon.paseoHome,
|
||||||
PASEO_HOST: "",
|
PASEO_HOST: "",
|
||||||
@@ -26,12 +26,11 @@ try {
|
|||||||
const status = JSON.parse(result.stdout);
|
const status = JSON.parse(result.stdout);
|
||||||
|
|
||||||
assert.strictEqual(status.localDaemon, "running");
|
assert.strictEqual(status.localDaemon, "running");
|
||||||
assert.strictEqual(status.connectedDaemon, "auth_required");
|
assert.strictEqual(status.connectedDaemon, "reachable");
|
||||||
assert(!("runningAgents" in status), "status should not fetch agent counts");
|
assert(!("runningAgents" in status), "status should not fetch agent counts");
|
||||||
assert(!("idleAgents" in status), "status should not fetch agent counts");
|
assert(!("idleAgents" in status), "status should not fetch agent counts");
|
||||||
assert.match(status.note, /Password required/i);
|
assert.doesNotMatch(status.note ?? "", /Password required|not reachable/i);
|
||||||
assert.doesNotMatch(status.note, /not reachable/i);
|
console.log("✓ missing password uses local credential\n");
|
||||||
console.log("✓ missing password reports auth_required\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ const daemon = await startTestDaemon({
|
|||||||
env: { PASEO_PASSWORD: "shared-secret" },
|
env: { PASEO_PASSWORD: "shared-secret" },
|
||||||
});
|
});
|
||||||
|
|
||||||
async function lsError(password: string) {
|
async function lsError(password: string, remoteClient = false) {
|
||||||
const result = await runLocalPaseo(["ls", "--json"], {
|
const result = await runLocalPaseo(["ls", "--json"], {
|
||||||
PASEO_HOME: daemon.paseoHome,
|
PASEO_HOME: remoteClient ? "" : daemon.paseoHome,
|
||||||
PASEO_HOST: "",
|
PASEO_HOST: remoteClient ? `127.0.0.1:${daemon.port}` : "",
|
||||||
PASEO_PASSWORD: password,
|
PASEO_PASSWORD: password,
|
||||||
});
|
});
|
||||||
assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password");
|
assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password");
|
||||||
@@ -26,13 +26,13 @@ async function lsError(password: string) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
{
|
{
|
||||||
console.log("Test 1: missing password asks for PASEO_PASSWORD, not a daemon start");
|
console.log("Test 1: a client outside the daemon home needs PASEO_PASSWORD");
|
||||||
const error = await lsError("");
|
const error = await lsError("", true);
|
||||||
assert.strictEqual(error.code, "AUTH_REQUIRED");
|
assert.strictEqual(error.code, "AUTH_REQUIRED");
|
||||||
assert.match(error.message, /Password required/);
|
assert.match(error.message, /Password required/);
|
||||||
assert.match(error.details, /PASEO_PASSWORD/);
|
assert.match(error.details, /PASEO_PASSWORD/);
|
||||||
assert.doesNotMatch(error.details, /daemon start/);
|
assert.doesNotMatch(error.details, /daemon start/);
|
||||||
console.log("✓ missing password points at PASEO_PASSWORD\n");
|
console.log("✓ client without the local credential is asked for PASEO_PASSWORD\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export class ConnectionSubscriptions extends OwnedSubscriptions {
|
|||||||
// Protocol support belongs to the installed client. Only browser hosting needs
|
// Protocol support belongs to the installed client. Only browser hosting needs
|
||||||
// a resource supplied by the caller. Keep this exhaustive as the protocol evolves.
|
// a resource supplied by the caller. Keep this exhaustive as the protocol evolves.
|
||||||
export const DEFAULT_CLIENT_CAPABILITIES = {
|
export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||||
|
[CLIENT_CAPS.helloRejection]: true,
|
||||||
[CLIENT_CAPS.ownedSubscriptions]: true,
|
[CLIENT_CAPS.ownedSubscriptions]: true,
|
||||||
[CLIENT_CAPS.allProviders]: true,
|
[CLIENT_CAPS.allProviders]: true,
|
||||||
[CLIENT_CAPS.selectiveAgentTimeline]: true,
|
[CLIENT_CAPS.selectiveAgentTimeline]: true,
|
||||||
|
|||||||
@@ -20,12 +20,19 @@ export function createRelayE2eeTransportFactory(args: {
|
|||||||
daemonPublicKeyB64: string;
|
daemonPublicKeyB64: string;
|
||||||
logger: TransportLogger;
|
logger: TransportLogger;
|
||||||
}): DaemonTransportFactory {
|
}): DaemonTransportFactory {
|
||||||
return ({ url, headers }) => {
|
return ({ url }) => {
|
||||||
const base = args.baseFactory({ url, headers });
|
const base = args.baseFactory({ url });
|
||||||
return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger);
|
return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createRelayTransportFactory(
|
||||||
|
baseFactory: DaemonTransportFactory,
|
||||||
|
): DaemonTransportFactory {
|
||||||
|
// The relay upgrade precedes E2EE. Only the encrypted hello may carry daemon credentials.
|
||||||
|
return ({ url }) => baseFactory({ url });
|
||||||
|
}
|
||||||
|
|
||||||
export function createEncryptedTransport(
|
export function createEncryptedTransport(
|
||||||
base: DaemonTransport,
|
base: DaemonTransport,
|
||||||
daemonPublicKeyB64: string,
|
daemonPublicKeyB64: string,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export {
|
|||||||
export {
|
export {
|
||||||
createEncryptedTransport,
|
createEncryptedTransport,
|
||||||
createRelayE2eeTransportFactory,
|
createRelayE2eeTransportFactory,
|
||||||
|
createRelayTransportFactory,
|
||||||
} from "./daemon-client-relay-e2ee-transport.js";
|
} from "./daemon-client-relay-e2ee-transport.js";
|
||||||
export {
|
export {
|
||||||
bindWsHandler,
|
bindWsHandler,
|
||||||
|
|||||||
@@ -854,6 +854,142 @@ test("passes password as HTTP bearer header and WebSocket subprotocol", async ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps relay upgrade credentials out of the socket request", async () => {
|
||||||
|
const mock = createMockTransport();
|
||||||
|
const requests: Array<{ url: string; headers?: Record<string, string>; protocols?: string[] }> =
|
||||||
|
[];
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
|
||||||
|
clientId: "clsk_relay_auth_test",
|
||||||
|
password: "shared-secret",
|
||||||
|
authHeader: "Bearer shared-secret",
|
||||||
|
e2ee: { enabled: true, daemonPublicKeyB64: "daemon-public-key" },
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: (request) => {
|
||||||
|
requests.push(request);
|
||||||
|
return mock.transport;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
void client.connect();
|
||||||
|
await vi.waitFor(() => expect(requests).toHaveLength(1));
|
||||||
|
expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses relay password auth without an encrypted hello", async () => {
|
||||||
|
const requests: unknown[] = [];
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
|
||||||
|
clientId: "clsk_unencrypted_relay_test",
|
||||||
|
password: "shared-secret",
|
||||||
|
connectTimeoutMs: 50,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: (request) => {
|
||||||
|
requests.push(request);
|
||||||
|
return createMockTransport().transport;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
await expect(client.connect()).rejects.toThrow("Relay credentials require E2EE");
|
||||||
|
expect(requests).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops reconnecting after a password rejection on an established connection", async () => {
|
||||||
|
const socket = createMockTransport();
|
||||||
|
let attempts = 0;
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_reconnect_auth_test",
|
||||||
|
password: "old-secret",
|
||||||
|
reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 },
|
||||||
|
transportFactory: () => {
|
||||||
|
attempts += 1;
|
||||||
|
return socket.transport;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
const connected = client.connect();
|
||||||
|
socket.triggerOpen();
|
||||||
|
await connected;
|
||||||
|
socket.triggerMessage(JSON.stringify({ type: "hello.rejected", reason: "incorrect_password" }));
|
||||||
|
socket.triggerClose({ code: 4003, reason: "Incorrect password" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
expect(client.authFailureReason).toBe("incorrect_password");
|
||||||
|
expect(attempts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends a password containing spaces in hello without an invalid WebSocket subprotocol", async () => {
|
||||||
|
const mock = createMockTransport();
|
||||||
|
const transportFactory = vi.fn(() => mock.transport);
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
password: "two words",
|
||||||
|
logger: createMockLogger(),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
const connected = client.connect();
|
||||||
|
mock.triggerOpen({ preserveSent: true });
|
||||||
|
await connected;
|
||||||
|
expect(transportFactory).toHaveBeenCalledWith({
|
||||||
|
url: "ws://test",
|
||||||
|
headers: {},
|
||||||
|
});
|
||||||
|
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
|
||||||
|
type: "hello",
|
||||||
|
auth: { kind: "password", password: "two words" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses a local credential over a saved password when the desktop bridge provides one", async () => {
|
||||||
|
const mock = createMockTransport();
|
||||||
|
const transportFactory = vi.fn(() => mock.transport);
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "desktop-managed",
|
||||||
|
password: "stale-password",
|
||||||
|
localCredential: async () => "current-local-token",
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
const connected = client.connect();
|
||||||
|
await vi.waitFor(() => expect(transportFactory).toHaveBeenCalled());
|
||||||
|
mock.triggerOpen({ preserveSent: true });
|
||||||
|
await connected;
|
||||||
|
expect(transportFactory).toHaveBeenCalledWith({ url: "ws://test", headers: {} });
|
||||||
|
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
|
||||||
|
type: "hello",
|
||||||
|
auth: { kind: "localCredential", token: "current-local-token" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the saved host password when the desktop bridge has no credential for the target", async () => {
|
||||||
|
const mock = createMockTransport();
|
||||||
|
const transportFactory = vi.fn(() => mock.transport);
|
||||||
|
const localCredential = vi.fn(async () => undefined);
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://remote-host",
|
||||||
|
clientId: "remote-saved-host",
|
||||||
|
password: "saved-password",
|
||||||
|
localCredential,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
const connected = client.connect();
|
||||||
|
await vi.waitFor(() => expect(transportFactory).toHaveBeenCalled());
|
||||||
|
mock.triggerOpen({ preserveSent: true });
|
||||||
|
await connected;
|
||||||
|
expect(localCredential).toHaveBeenCalledOnce();
|
||||||
|
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
|
||||||
|
type: "hello",
|
||||||
|
auth: { kind: "password", password: "saved-password" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("advertises client capabilities in hello", async () => {
|
test("advertises client capabilities in hello", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
@@ -900,6 +1036,7 @@ test("advertises client capabilities in hello", async () => {
|
|||||||
timeline_notifications: true,
|
timeline_notifications: true,
|
||||||
plugin_timeline_items: true,
|
plugin_timeline_items: true,
|
||||||
workspace_setup_blocked: true,
|
workspace_setup_blocked: true,
|
||||||
|
hello_rejection: true,
|
||||||
browser_host: {
|
browser_host: {
|
||||||
supportedCommands: ["list_tabs"],
|
supportedCommands: ["list_tabs"],
|
||||||
hostKind: "desktop app",
|
hostKind: "desktop app",
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ import {
|
|||||||
} from "@getpaseo/protocol/binary-frames/index";
|
} from "@getpaseo/protocol/binary-frames/index";
|
||||||
import {
|
import {
|
||||||
createRelayE2eeTransportFactory,
|
createRelayE2eeTransportFactory,
|
||||||
|
createRelayTransportFactory,
|
||||||
createWebSocketTransportFactory,
|
createWebSocketTransportFactory,
|
||||||
decodeMessageData,
|
decodeMessageData,
|
||||||
defaultWebSocketFactory,
|
defaultWebSocketFactory,
|
||||||
@@ -216,6 +217,68 @@ function normalizePassword(value: string | undefined): string | null {
|
|||||||
return value.length > 0 ? value : null;
|
return value.length > 0 ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compatibleBearerPassword(password: string | null): string | null {
|
||||||
|
return password && /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(password) ? password : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HelloAuth =
|
||||||
|
| { kind: "password"; password: string }
|
||||||
|
| { kind: "localCredential"; token: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export type DaemonAuthFailureReason = "password_required" | "incorrect_password";
|
||||||
|
|
||||||
|
export class DaemonAuthenticationError extends Error {
|
||||||
|
readonly reason: DaemonAuthFailureReason;
|
||||||
|
|
||||||
|
constructor(reason: DaemonAuthFailureReason) {
|
||||||
|
super(reason === "password_required" ? "Password required" : "Incorrect password");
|
||||||
|
this.name = "DaemonAuthenticationError";
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDaemonAuthFailureReason(error: unknown): DaemonAuthFailureReason | null {
|
||||||
|
return error instanceof DaemonAuthenticationError ? error.reason : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authFailureFromLegacyClose(event: unknown): DaemonAuthFailureReason | null {
|
||||||
|
if (!event || typeof event !== "object" || !("reason" in event)) return null;
|
||||||
|
if (event.reason === "Password required") return "password_required";
|
||||||
|
if (event.reason === "Incorrect password") return "incorrect_password";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseConnectionAuth(
|
||||||
|
config: DaemonClientConfig,
|
||||||
|
localCredential: string | undefined,
|
||||||
|
): { helloAuth: HelloAuth; headers: Record<string, string>; protocols?: string[] } {
|
||||||
|
const password = normalizePassword(config.password);
|
||||||
|
let helloAuth: HelloAuth;
|
||||||
|
if (localCredential) helloAuth = { kind: "localCredential", token: localCredential };
|
||||||
|
else if (password) helloAuth = { kind: "password", password };
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const compatibleBearer = localCredential ? null : compatibleBearerPassword(password);
|
||||||
|
// COMPAT(headerAuth): added in v0.9.1, remove after 2027-03-24.
|
||||||
|
if (compatibleBearer) headers.Authorization = `Bearer ${compatibleBearer}`;
|
||||||
|
else if (!localCredential && config.authHeader) headers.Authorization = config.authHeader;
|
||||||
|
return {
|
||||||
|
helloAuth,
|
||||||
|
headers,
|
||||||
|
...(compatibleBearer ? { protocols: [`paseo.bearer.${compatibleBearer}`] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConnectionAuth(
|
||||||
|
config: DaemonClientConfig,
|
||||||
|
): ReturnType<typeof chooseConnectionAuth> | Promise<ReturnType<typeof chooseConnectionAuth>> {
|
||||||
|
const resolution = config.localCredential?.();
|
||||||
|
if (resolution instanceof Promise) {
|
||||||
|
return resolution.then((credential) => chooseConnectionAuth(config, credential));
|
||||||
|
}
|
||||||
|
return chooseConnectionAuth(config, resolution);
|
||||||
|
}
|
||||||
|
|
||||||
function extractCorrelatedResponseIdentity(input: unknown): CorrelatedResponseIdentity | null {
|
function extractCorrelatedResponseIdentity(input: unknown): CorrelatedResponseIdentity | null {
|
||||||
if (!input || typeof input !== "object") {
|
if (!input || typeof input !== "object") {
|
||||||
return null;
|
return null;
|
||||||
@@ -330,6 +393,7 @@ export interface DaemonClientConfig {
|
|||||||
appVersion?: string;
|
appVersion?: string;
|
||||||
runtimeGeneration?: number | null;
|
runtimeGeneration?: number | null;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
localCredential?: () => string | undefined | Promise<string | undefined>;
|
||||||
authHeader?: string;
|
authHeader?: string;
|
||||||
suppressSendErrors?: boolean;
|
suppressSendErrors?: boolean;
|
||||||
transportFactory?: DaemonTransportFactory;
|
transportFactory?: DaemonTransportFactory;
|
||||||
@@ -1147,6 +1211,7 @@ export class DaemonClient {
|
|||||||
failed: (error) => this.logger.error({ err: error }, "Subscription failed"),
|
failed: (error) => this.logger.error({ err: error }, "Subscription failed"),
|
||||||
});
|
});
|
||||||
private transport: DaemonTransport | null = null;
|
private transport: DaemonTransport | null = null;
|
||||||
|
private helloAuth: HelloAuth;
|
||||||
private transportCleanup: Array<() => void> = [];
|
private transportCleanup: Array<() => void> = [];
|
||||||
private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set();
|
private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set();
|
||||||
private messageHandlers: Map<
|
private messageHandlers: Map<
|
||||||
@@ -1166,6 +1231,7 @@ export class DaemonClient {
|
|||||||
private connectResolve: (() => void) | null = null;
|
private connectResolve: (() => void) | null = null;
|
||||||
private connectReject: ((error: Error) => void) | null = null;
|
private connectReject: ((error: Error) => void) | null = null;
|
||||||
private lastErrorValue: string | null = null;
|
private lastErrorValue: string | null = null;
|
||||||
|
private authFailureReasonValue: DaemonAuthFailureReason | null = null;
|
||||||
private connectionState: ConnectionState = { status: "idle" };
|
private connectionState: ConnectionState = { status: "idle" };
|
||||||
private readonly terminalStreams = new TerminalStreamRouter();
|
private readonly terminalStreams = new TerminalStreamRouter();
|
||||||
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>();
|
||||||
@@ -1257,7 +1323,7 @@ export class DaemonClient {
|
|||||||
return this.connectPromise;
|
return this.connectPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private attemptConnect(): void {
|
private async attemptConnect(): Promise<void> {
|
||||||
if (this.connectionState.status === "disposed") {
|
if (this.connectionState.status === "disposed") {
|
||||||
this.rejectConnect(new Error("Daemon client is disposed"));
|
this.rejectConnect(new Error("Daemon client is disposed"));
|
||||||
return;
|
return;
|
||||||
@@ -1277,33 +1343,31 @@ export class DaemonClient {
|
|||||||
this.reconnectTimeout = null;
|
this.reconnectTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
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 {
|
try {
|
||||||
|
const resolution = resolveConnectionAuth(this.config);
|
||||||
|
const selected = resolution instanceof Promise ? await resolution : resolution;
|
||||||
|
if (!this.shouldReconnect) return;
|
||||||
|
this.helloAuth = selected.helloAuth;
|
||||||
// Reconnect can overlap with browser close/error delivery ordering.
|
// Reconnect can overlap with browser close/error delivery ordering.
|
||||||
// Always dispose previous transport before constructing the next one.
|
// Always dispose previous transport before constructing the next one.
|
||||||
this.disposeTransport();
|
this.disposeTransport();
|
||||||
const baseTransportFactory =
|
const baseTransportFactory =
|
||||||
this.config.transportFactory ??
|
this.config.transportFactory ??
|
||||||
createWebSocketTransportFactory(this.config.webSocketFactory ?? defaultWebSocketFactory);
|
createWebSocketTransportFactory(this.config.webSocketFactory ?? defaultWebSocketFactory);
|
||||||
const shouldUseRelayE2ee =
|
const isRelayTransport = isRelayClientWebSocketUrl(this.config.url);
|
||||||
this.config.e2ee?.enabled === true && isRelayClientWebSocketUrl(this.config.url);
|
const shouldUseRelayE2ee = this.config.e2ee?.enabled === true && isRelayTransport;
|
||||||
|
this.assertEncryptedRelayAuth(selected.helloAuth, isRelayTransport);
|
||||||
|
|
||||||
let transportFactory = baseTransportFactory;
|
let transportFactory = isRelayTransport
|
||||||
|
? createRelayTransportFactory(baseTransportFactory)
|
||||||
|
: baseTransportFactory;
|
||||||
if (shouldUseRelayE2ee) {
|
if (shouldUseRelayE2ee) {
|
||||||
const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64;
|
const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64;
|
||||||
if (!daemonPublicKeyB64) {
|
if (!daemonPublicKeyB64) {
|
||||||
throw new Error("daemonPublicKeyB64 is required for relay E2EE");
|
throw new Error("daemonPublicKeyB64 is required for relay E2EE");
|
||||||
}
|
}
|
||||||
transportFactory = createRelayE2eeTransportFactory({
|
transportFactory = createRelayE2eeTransportFactory({
|
||||||
baseFactory: baseTransportFactory,
|
baseFactory: transportFactory,
|
||||||
daemonPublicKeyB64,
|
daemonPublicKeyB64,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
@@ -1311,11 +1375,12 @@ export class DaemonClient {
|
|||||||
const transportUrl = this.resolveTransportUrlForAttempt();
|
const transportUrl = this.resolveTransportUrlForAttempt();
|
||||||
const transport = transportFactory({
|
const transport = transportFactory({
|
||||||
url: transportUrl,
|
url: transportUrl,
|
||||||
headers,
|
headers: selected.headers,
|
||||||
...(protocols ? { protocols } : {}),
|
...(selected.protocols ? { protocols: selected.protocols } : {}),
|
||||||
});
|
});
|
||||||
this.transport = transport;
|
this.transport = transport;
|
||||||
this.lastServerInfoMessage = null;
|
this.lastServerInfoMessage = null;
|
||||||
|
this.authFailureReasonValue = null;
|
||||||
|
|
||||||
this.updateConnectionState(
|
this.updateConnectionState(
|
||||||
{
|
{
|
||||||
@@ -1346,7 +1411,7 @@ export class DaemonClient {
|
|||||||
this.pendingGenericTransportErrorTimeout = null;
|
this.pendingGenericTransportErrorTimeout = null;
|
||||||
}
|
}
|
||||||
this.lastErrorValue = null;
|
this.lastErrorValue = null;
|
||||||
this.sendHelloMessage();
|
void this.sendHelloMessage();
|
||||||
}),
|
}),
|
||||||
transport.onClose((event) => {
|
transport.onClose((event) => {
|
||||||
this.resetConnectTimeout();
|
this.resetConnectTimeout();
|
||||||
@@ -1354,7 +1419,10 @@ export class DaemonClient {
|
|||||||
clearTimeout(this.pendingGenericTransportErrorTimeout);
|
clearTimeout(this.pendingGenericTransportErrorTimeout);
|
||||||
this.pendingGenericTransportErrorTimeout = null;
|
this.pendingGenericTransportErrorTimeout = null;
|
||||||
}
|
}
|
||||||
const reason = describeTransportClose(event);
|
this.authFailureReasonValue ??= authFailureFromLegacyClose(event);
|
||||||
|
const reason = this.authFailureReasonValue
|
||||||
|
? new DaemonAuthenticationError(this.authFailureReasonValue).message
|
||||||
|
: describeTransportClose(event);
|
||||||
if (reason) {
|
if (reason) {
|
||||||
this.lastErrorValue = reason;
|
this.lastErrorValue = reason;
|
||||||
}
|
}
|
||||||
@@ -1420,6 +1488,12 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertEncryptedRelayAuth(helloAuth: HelloAuth, isRelayTransport: boolean): void {
|
||||||
|
if (!isRelayTransport || !helloAuth || this.config.e2ee?.enabled === true) return;
|
||||||
|
this.setReconnectEnabled(false);
|
||||||
|
throw new Error("Relay credentials require E2EE");
|
||||||
|
}
|
||||||
|
|
||||||
private resolveConnect(): void {
|
private resolveConnect(): void {
|
||||||
if (this.connectResolve) {
|
if (this.connectResolve) {
|
||||||
this.connectResolve();
|
this.connectResolve();
|
||||||
@@ -1537,6 +1611,10 @@ export class DaemonClient {
|
|||||||
return this.lastErrorValue;
|
return this.lastErrorValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get authFailureReason(): DaemonAuthFailureReason | null {
|
||||||
|
return this.authFailureReasonValue;
|
||||||
|
}
|
||||||
|
|
||||||
getLastLivenessRttMs(): number | null {
|
getLastLivenessRttMs(): number | null {
|
||||||
return this.lastLivenessRttMs;
|
return this.lastLivenessRttMs;
|
||||||
}
|
}
|
||||||
@@ -5993,8 +6071,9 @@ export class DaemonClient {
|
|||||||
return this.config.url;
|
return this.config.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendHelloMessage(): void {
|
private async sendHelloMessage(): Promise<void> {
|
||||||
if (!this.transport) {
|
const transport = this.transport;
|
||||||
|
if (!transport) {
|
||||||
this.scheduleReconnect({
|
this.scheduleReconnect({
|
||||||
reason: "Transport unavailable before hello",
|
reason: "Transport unavailable before hello",
|
||||||
event: "HELLO_TRANSPORT_MISSING",
|
event: "HELLO_TRANSPORT_MISSING",
|
||||||
@@ -6004,11 +6083,14 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (this.transport !== transport) return;
|
||||||
|
const auth = this.helloAuth;
|
||||||
this.sendJsonMessage("hello", "hello", {
|
this.sendJsonMessage("hello", "hello", {
|
||||||
type: "hello",
|
type: "hello",
|
||||||
clientId: this.config.clientId,
|
clientId: this.config.clientId,
|
||||||
clientType: this.config.clientType ?? "cli",
|
clientType: this.config.clientType ?? "cli",
|
||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
|
...(auth ? { auth } : {}),
|
||||||
capabilities: {
|
capabilities: {
|
||||||
...DEFAULT_CLIENT_CAPABILITIES,
|
...DEFAULT_CLIENT_CAPABILITIES,
|
||||||
...this.config.capabilities,
|
...this.config.capabilities,
|
||||||
@@ -6154,6 +6236,18 @@ export class DaemonClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.data.type === "hello.rejected") {
|
||||||
|
const reasonMessage = {
|
||||||
|
password_required: "Password required",
|
||||||
|
incorrect_password: "Incorrect password",
|
||||||
|
incompatible_protocol: "Incompatible protocol version",
|
||||||
|
};
|
||||||
|
this.lastErrorValue = reasonMessage[parsed.data.reason];
|
||||||
|
this.authFailureReasonValue =
|
||||||
|
parsed.data.reason === "incompatible_protocol" ? null : parsed.data.reason;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.traceInstant("paseo.ws.message.inbound", {
|
this.traceInstant("paseo.ws.message.inbound", {
|
||||||
envelopeType: "session",
|
envelopeType: "session",
|
||||||
messageType: parsed.data.message.type,
|
messageType: parsed.data.message.type,
|
||||||
@@ -6352,13 +6446,19 @@ export class DaemonClient {
|
|||||||
this.terminalStreams.clearSlots();
|
this.terminalStreams.clearSlots();
|
||||||
this.lastServerInfoMessage = null;
|
this.lastServerInfoMessage = null;
|
||||||
|
|
||||||
|
if (this.authFailureReasonValue) this.setReconnectEnabled(false);
|
||||||
|
|
||||||
if (wasDisposed) {
|
if (wasDisposed) {
|
||||||
this.rejectConnect(new Error(reason ?? "Daemon client is disposed"));
|
this.rejectConnect(new Error(reason ?? "Daemon client is disposed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.emitDisconnectedStateForReconnect(reason, input);
|
this.emitDisconnectedStateForReconnect(reason, input);
|
||||||
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
|
if (!this.shouldReconnect || this.config.reconnect?.enabled === false) {
|
||||||
this.rejectConnect(new Error(reason ?? "Transport disconnected before connect"));
|
this.rejectConnect(
|
||||||
|
this.authFailureReasonValue
|
||||||
|
? new DaemonAuthenticationError(this.authFailureReasonValue)
|
||||||
|
: new Error(reason ?? "Transport disconnected before connect"),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { hostname, tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -51,8 +51,9 @@ vi.mock("electron-log/main", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@getpaseo/server/daemon-control", () => ({
|
vi.mock("@getpaseo/server/daemon-control", async (importOriginal) => ({
|
||||||
resolvePaseoHome: vi.fn(() => mocks.paseoHome),
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
resolvePaseoHome: () => mocks.paseoHome,
|
||||||
spawnProcess: mocks.spawnProcess,
|
spawnProcess: mocks.spawnProcess,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -123,4 +124,25 @@ describe("daemon-manager commands", () => {
|
|||||||
currentVersion: "1.2.3",
|
currentVersion: "1.2.3",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a local credential only for its live managed daemon listen", async () => {
|
||||||
|
mkdirSync(mocks.paseoHome);
|
||||||
|
const token = "a".repeat(43);
|
||||||
|
writeFileSync(path.join(mocks.paseoHome, "local-credential"), `${token}\n`, { mode: 0o600 });
|
||||||
|
const lock = {
|
||||||
|
pid: process.pid,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
hostname: hostname(),
|
||||||
|
uid: process.getuid?.() ?? 0,
|
||||||
|
listen: "127.0.0.1:6799",
|
||||||
|
desktopManaged: true,
|
||||||
|
};
|
||||||
|
const lockPath = path.join(mocks.paseoHome, "paseo.pid");
|
||||||
|
writeFileSync(lockPath, JSON.stringify(lock));
|
||||||
|
const handler = createDaemonCommandHandlers().desktop_local_credential;
|
||||||
|
expect(await handler({ listen: "localhost:6799" })).toBe(token);
|
||||||
|
expect(await handler({ listen: "remote:6799" })).toBeNull();
|
||||||
|
writeFileSync(lockPath, JSON.stringify({ ...lock, desktopManaged: false }));
|
||||||
|
expect(await handler({ listen: "localhost:6799" })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
stopDaemonInstance,
|
stopDaemonInstance,
|
||||||
readDaemonInstance,
|
readDaemonInstance,
|
||||||
isSameDaemonInstance,
|
isSameDaemonInstance,
|
||||||
|
readLocalCredentialForTarget,
|
||||||
type DaemonInstance,
|
type DaemonInstance,
|
||||||
} from "@getpaseo/server/daemon-control";
|
} from "@getpaseo/server/daemon-control";
|
||||||
import {
|
import {
|
||||||
@@ -399,6 +400,11 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
|||||||
runningUnderARM64Translation: isRunningUnderARM64Translation(),
|
runningUnderARM64Translation: isRunningUnderARM64Translation(),
|
||||||
}),
|
}),
|
||||||
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
|
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
|
||||||
|
desktop_local_credential: async (args) => {
|
||||||
|
const instance = await readDaemonInstance(getPaseoHome());
|
||||||
|
if (!instance?.desktopManaged || typeof args?.listen !== "string") return null;
|
||||||
|
return readLocalCredentialForTarget(getPaseoHome(), args.listen);
|
||||||
|
},
|
||||||
start_desktop_daemon: () => startDaemon(),
|
start_desktop_daemon: () => startDaemon(),
|
||||||
stop_desktop_daemon: (args) =>
|
stop_desktop_daemon: (args) =>
|
||||||
stopDesktopDaemon(
|
stopDesktopDaemon(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const CLIENT_CAPS = {
|
export const CLIENT_CAPS = {
|
||||||
|
helloRejection: "hello_rejection",
|
||||||
// COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09.
|
// COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09.
|
||||||
ownedSubscriptions: "owned_subscriptions",
|
ownedSubscriptions: "owned_subscriptions",
|
||||||
// Clients outside the Paseo app version series can explicitly accept provider IDs.
|
// Clients outside the Paseo app version series can explicitly accept provider IDs.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
extractHostPortFromWebSocketUrl,
|
extractHostPortFromWebSocketUrl,
|
||||||
normalizeRelayProtocolVersion,
|
normalizeRelayProtocolVersion,
|
||||||
parseConnectionUri,
|
parseConnectionUri,
|
||||||
|
parseRelayConnectionUri,
|
||||||
|
serializeRelayConnectionUri,
|
||||||
serializeConnectionUri,
|
serializeConnectionUri,
|
||||||
serializeConnectionUriForStorage,
|
serializeConnectionUriForStorage,
|
||||||
shouldUseTlsForDefaultHostedRelay,
|
shouldUseTlsForDefaultHostedRelay,
|
||||||
@@ -211,3 +213,22 @@ describe("shouldUseTlsForDefaultHostedRelay", () => {
|
|||||||
expect(shouldUseTlsForDefaultHostedRelay("not-an-endpoint")).toBe(false);
|
expect(shouldUseTlsForDefaultHostedRelay("not-an-endpoint")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("relay connection URI", () => {
|
||||||
|
test("round-trips the offer and password through a direct URI and connect wrapper", () => {
|
||||||
|
const parts = {
|
||||||
|
offer: {
|
||||||
|
v: 2 as const,
|
||||||
|
serverId: "srv_test",
|
||||||
|
daemonPublicKeyB64: "abc+/=",
|
||||||
|
relay: { endpoint: "relay.paseo.sh:443", useTls: true },
|
||||||
|
},
|
||||||
|
password: "two words",
|
||||||
|
};
|
||||||
|
const uri = serializeRelayConnectionUri(parts);
|
||||||
|
expect(parseRelayConnectionUri(uri)).toEqual(parts);
|
||||||
|
expect(
|
||||||
|
parseRelayConnectionUri(`https://app.paseo.sh/#connect=${encodeURIComponent(uri)}`),
|
||||||
|
).toEqual(parts);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ConnectionOfferV2 } from "./connection-offer.js";
|
||||||
|
|
||||||
export interface HostPortParts {
|
export interface HostPortParts {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -12,6 +14,55 @@ export interface ParsedConnectionUri extends ConnectionUriParts {
|
|||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParsedRelayConnectionUri {
|
||||||
|
offer: ConnectionOfferV2;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRelayConnectionUri(input: string): ParsedRelayConnectionUri {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
const connectIndex = trimmed.indexOf("#connect=");
|
||||||
|
const uri =
|
||||||
|
connectIndex >= 0
|
||||||
|
? decodeURIComponent(trimmed.slice(connectIndex + "#connect=".length))
|
||||||
|
: trimmed;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(uri);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid relay connection URI");
|
||||||
|
}
|
||||||
|
if (url.protocol !== "relay:" || !url.hostname || !url.port || url.username || url.password) {
|
||||||
|
throw new Error("Invalid relay connection URI");
|
||||||
|
}
|
||||||
|
const serverId = decodeURIComponent(url.pathname.slice(1));
|
||||||
|
const key = url.searchParams.get("key");
|
||||||
|
if (!serverId || !key || serverId.includes("/")) {
|
||||||
|
throw new Error("Relay connection URI requires a server id and key");
|
||||||
|
}
|
||||||
|
const endpoint = normalizeHostPort(url.host);
|
||||||
|
const password = url.searchParams.get("password") || undefined;
|
||||||
|
return {
|
||||||
|
offer: {
|
||||||
|
v: 2,
|
||||||
|
serverId,
|
||||||
|
daemonPublicKeyB64: key,
|
||||||
|
relay: { endpoint, useTls: url.searchParams.get("ssl") === "true" },
|
||||||
|
},
|
||||||
|
...(password ? { password } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeRelayConnectionUri(parts: ParsedRelayConnectionUri): string {
|
||||||
|
const url = new URL(
|
||||||
|
`relay://${parts.offer.relay.endpoint}/${encodeURIComponent(parts.offer.serverId)}`,
|
||||||
|
);
|
||||||
|
url.searchParams.set("key", parts.offer.daemonPublicKeyB64);
|
||||||
|
if (parts.offer.relay.useTls) url.searchParams.set("ssl", "true");
|
||||||
|
if (parts.password) url.searchParams.set("password", parts.password);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export type RelayRole = "server" | "client";
|
export type RelayRole = "server" | "client";
|
||||||
export type RelayProtocolVersion = "1" | "2";
|
export type RelayProtocolVersion = "1" | "2";
|
||||||
|
|
||||||
|
|||||||
@@ -3514,6 +3514,7 @@ const ServerCapabilitiesFromUnknownSchema = z
|
|||||||
export const ServerInfoStatusPayloadSchema = z
|
export const ServerInfoStatusPayloadSchema = z
|
||||||
.object({
|
.object({
|
||||||
status: z.literal("server_info"),
|
status: z.literal("server_info"),
|
||||||
|
protocolVersion: z.number().int().optional(),
|
||||||
serverId: z.string().trim().min(1),
|
serverId: z.string().trim().min(1),
|
||||||
hostname: ServerInfoHostnameSchema.optional(),
|
hostname: ServerInfoHostnameSchema.optional(),
|
||||||
version: ServerInfoVersionSchema.optional(),
|
version: ServerInfoVersionSchema.optional(),
|
||||||
@@ -7411,10 +7412,17 @@ export const WSHelloMessageSchema = z.object({
|
|||||||
clientId: z.string().min(1),
|
clientId: z.string().min(1),
|
||||||
clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]),
|
clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]),
|
||||||
protocolVersion: z.number().int(),
|
protocolVersion: z.number().int(),
|
||||||
|
auth: z
|
||||||
|
.discriminatedUnion("kind", [
|
||||||
|
z.object({ kind: z.literal("password"), password: z.string() }),
|
||||||
|
z.object({ kind: z.literal("localCredential"), token: z.string() }),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
appVersion: z.string().optional(),
|
appVersion: z.string().optional(),
|
||||||
capabilities: z
|
capabilities: z
|
||||||
.object({
|
.object({
|
||||||
voice: z.boolean().optional(),
|
voice: z.boolean().optional(),
|
||||||
|
[CLIENT_CAPS.helloRejection]: z.boolean().optional(),
|
||||||
pushNotifications: z.boolean().optional(),
|
pushNotifications: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.explicitEventSubscriptions]: z.boolean().optional(),
|
[CLIENT_CAPS.explicitEventSubscriptions]: z.boolean().optional(),
|
||||||
[CLIENT_CAPS.allProviders]: z.boolean().optional(),
|
[CLIENT_CAPS.allProviders]: z.boolean().optional(),
|
||||||
@@ -7450,6 +7458,12 @@ export const WSSessionOutboundSchema = z.object({
|
|||||||
message: SessionOutboundMessageSchema,
|
message: SessionOutboundMessageSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const WSHelloRejectedMessageSchema = z.object({
|
||||||
|
type: z.literal("hello.rejected"),
|
||||||
|
reason: z.enum(["password_required", "incorrect_password", "incompatible_protocol"]),
|
||||||
|
accepts: z.array(z.literal("password")),
|
||||||
|
});
|
||||||
|
|
||||||
// Complete WebSocket message schemas
|
// Complete WebSocket message schemas
|
||||||
export const WSInboundMessageSchema = z.discriminatedUnion("type", [
|
export const WSInboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
WSPingMessageSchema,
|
WSPingMessageSchema,
|
||||||
@@ -7461,6 +7475,7 @@ export const WSInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
export const WSOutboundMessageSchema = z.discriminatedUnion("type", [
|
export const WSOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
WSPongMessageSchema,
|
WSPongMessageSchema,
|
||||||
WSSessionOutboundSchema,
|
WSSessionOutboundSchema,
|
||||||
|
WSHelloRejectedMessageSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type WSInboundMessage = z.infer<typeof WSInboundMessageSchema>;
|
export type WSInboundMessage = z.infer<typeof WSInboundMessageSchema>;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
extractHttpBearerToken,
|
extractHttpBearerToken,
|
||||||
@@ -7,25 +10,27 @@ import {
|
|||||||
hashDaemonPassword,
|
hashDaemonPassword,
|
||||||
isAgentMcpRequestAuthorized,
|
isAgentMcpRequestAuthorized,
|
||||||
isBearerTokenValidAsync,
|
isBearerTokenValidAsync,
|
||||||
isBearerTokenValid,
|
|
||||||
shouldBypassBearerAuth,
|
shouldBypassBearerAuth,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
|
import { resolveSessionAdmission } from "./session-admission-auth.js";
|
||||||
|
import {
|
||||||
|
deleteLocalCredential,
|
||||||
|
readLocalCredentialForTarget,
|
||||||
|
writeLocalCredential,
|
||||||
|
} from "./local-credential.js";
|
||||||
|
|
||||||
const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
||||||
|
|
||||||
describe("daemon bearer validator", () => {
|
describe("daemon bearer validator", () => {
|
||||||
test("allows any token when no password is configured", () => {
|
test("allows any token when no password is configured", async () => {
|
||||||
expect(isBearerTokenValid({ password: undefined, token: null })).toBe(true);
|
expect(await isBearerTokenValidAsync({ password: undefined, token: null })).toBe(true);
|
||||||
expect(isBearerTokenValid({ password: undefined, token: "anything" })).toBe(true);
|
expect(await isBearerTokenValidAsync({ password: undefined, token: "anything" })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts the plaintext token against the bcrypt hash and rejects missing or wrong tokens", async () => {
|
test("accepts the plaintext token against the bcrypt hash and rejects missing or wrong tokens", async () => {
|
||||||
expect(
|
expect(
|
||||||
await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: "correct-password" }),
|
await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: "correct-password" }),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(isBearerTokenValid({ password: CORRECT_PASSWORD_HASH, token: "correct-password" })).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: null })).toBe(
|
expect(await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: null })).toBe(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -34,11 +39,11 @@ describe("daemon bearer validator", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hashes a password into a bcrypt value", () => {
|
test("hashes a password into a bcrypt value", async () => {
|
||||||
const hash = hashDaemonPassword("correct-password");
|
const hash = hashDaemonPassword("correct-password");
|
||||||
|
|
||||||
expect(hash).toMatch(/^\$2[aby]\$12\$/);
|
expect(hash).toMatch(/^\$2[aby]\$12\$/);
|
||||||
expect(isBearerTokenValid({ password: hash, token: "correct-password" })).toBe(true);
|
expect(await isBearerTokenValidAsync({ password: hash, token: "correct-password" })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("extracts HTTP bearer tokens", () => {
|
test("extracts HTTP bearer tokens", () => {
|
||||||
@@ -121,3 +126,88 @@ describe("agent MCP request authorizer", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hello admission", () => {
|
||||||
|
test("admits a stale local credential when no password is configured", async () => {
|
||||||
|
expect(
|
||||||
|
await resolveSessionAdmission({
|
||||||
|
credential: { kind: "localCredential", token: "stale" },
|
||||||
|
passwordHash: undefined,
|
||||||
|
localCredential: "current",
|
||||||
|
transport: "direct",
|
||||||
|
}),
|
||||||
|
).toMatchObject({ admission: { principalId: "owner" } });
|
||||||
|
});
|
||||||
|
test("accepts a password and current local credential, but rejects old and wrong credentials", async () => {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), "paseo-local-auth-"));
|
||||||
|
try {
|
||||||
|
const first = await writeLocalCredential(home);
|
||||||
|
const second = await writeLocalCredential(home);
|
||||||
|
const input = {
|
||||||
|
passwordHash: CORRECT_PASSWORD_HASH,
|
||||||
|
localCredential: second,
|
||||||
|
transport: "direct" as const,
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
await resolveSessionAdmission({
|
||||||
|
...input,
|
||||||
|
credential: { kind: "password", password: "correct-password" },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ admission: { principalId: "owner" } });
|
||||||
|
expect(
|
||||||
|
await resolveSessionAdmission({
|
||||||
|
...input,
|
||||||
|
credential: { kind: "localCredential", token: second },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ admission: { principalId: "owner" } });
|
||||||
|
expect(
|
||||||
|
await resolveSessionAdmission({
|
||||||
|
...input,
|
||||||
|
credential: { kind: "localCredential", token: first },
|
||||||
|
}),
|
||||||
|
).toEqual({ rejection: "incorrect_password" });
|
||||||
|
expect(
|
||||||
|
await resolveSessionAdmission({
|
||||||
|
...input,
|
||||||
|
credential: { kind: "password", password: "wrong" },
|
||||||
|
}),
|
||||||
|
).toEqual({ rejection: "incorrect_password" });
|
||||||
|
expect(
|
||||||
|
await resolveSessionAdmission({
|
||||||
|
...input,
|
||||||
|
transport: "relay",
|
||||||
|
credential: { kind: "password", password: "wrong" },
|
||||||
|
}),
|
||||||
|
).toEqual({ rejection: "incorrect_password" });
|
||||||
|
expect(await resolveSessionAdmission({ ...input, credential: undefined })).toEqual({
|
||||||
|
rejection: "password_required",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await resolveSessionAdmission({ ...input, transport: "relay", credential: undefined }),
|
||||||
|
).toMatchObject({ admission: { principalId: "owner" } });
|
||||||
|
} finally {
|
||||||
|
await deleteLocalCredential(home);
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes a private rotating credential and reads it only for the matching target", async () => {
|
||||||
|
const home = await mkdtemp(join(tmpdir(), "paseo-local-file-"));
|
||||||
|
try {
|
||||||
|
await writeFile(join(home, "paseo.pid"), JSON.stringify({ listen: "127.0.0.1:6767" }));
|
||||||
|
const first = await writeLocalCredential(home);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
expect((await stat(join(home, "local-credential"))).mode & 0o777).toBe(0o600);
|
||||||
|
}
|
||||||
|
expect(readLocalCredentialForTarget(home, "tcp://localhost:6767")).toBe(first);
|
||||||
|
expect(readLocalCredentialForTarget(home, "tcp://remote.example:6767")).toBeNull();
|
||||||
|
const second = await writeLocalCredential(home);
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect((await readFile(join(home, "local-credential"), "utf8")).trim()).toBe(second);
|
||||||
|
await deleteLocalCredential(home);
|
||||||
|
expect(readLocalCredentialForTarget(home, "localhost:6767")).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { compare, compareSync, hashSync } from "bcryptjs";
|
import { compare, hashSync } from "bcryptjs";
|
||||||
import { timingSafeEqual } from "node:crypto";
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import type { RequestHandler } from "express";
|
import type { RequestHandler } from "express";
|
||||||
|
import { matchesLocalCredential } from "./local-credential.js";
|
||||||
|
|
||||||
export const DAEMON_PASSWORD_BCRYPT_COST = 12;
|
export const DAEMON_PASSWORD_BCRYPT_COST = 12;
|
||||||
|
|
||||||
export interface DaemonAuthConfig {
|
export interface DaemonAuthConfig {
|
||||||
password?: string;
|
password?: string;
|
||||||
|
localCredential?: () => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BearerAuthRejectContext {
|
export interface BearerAuthRejectContext {
|
||||||
@@ -19,10 +21,6 @@ interface BearerValidationInput {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBearerTokenValid(input: BearerValidationInput): boolean {
|
|
||||||
return isBearerTokenValidSync(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function isBearerTokenValidAsync(input: BearerValidationInput): Promise<boolean> {
|
export async function isBearerTokenValidAsync(input: BearerValidationInput): Promise<boolean> {
|
||||||
if (!input.password) {
|
if (!input.password) {
|
||||||
return true;
|
return true;
|
||||||
@@ -34,17 +32,6 @@ export async function isBearerTokenValidAsync(input: BearerValidationInput): Pro
|
|||||||
return compare(input.token, input.password);
|
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 {
|
export function hashDaemonPassword(password: string): string {
|
||||||
return hashSync(password, DAEMON_PASSWORD_BCRYPT_COST);
|
return hashSync(password, DAEMON_PASSWORD_BCRYPT_COST);
|
||||||
}
|
}
|
||||||
@@ -101,7 +88,13 @@ export function createRequireBearerMiddleware(
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const token = extractHttpBearerToken(req.header("authorization"));
|
const token = extractHttpBearerToken(req.header("authorization"));
|
||||||
if (!(await isBearerTokenValidAsync({ password, token }))) {
|
const localCredential = req.path === "/api/status" ? auth?.localCredential?.() : null;
|
||||||
|
const isLocal =
|
||||||
|
localCredential !== null &&
|
||||||
|
localCredential !== undefined &&
|
||||||
|
token !== null &&
|
||||||
|
matchesLocalCredential(localCredential, token);
|
||||||
|
if (!isLocal && !(await isBearerTokenValidAsync({ password, token }))) {
|
||||||
onReject?.({
|
onReject?.({
|
||||||
path: req.path,
|
path: req.path,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
|
import { DaemonAuthenticationError, DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { readLocalCredentialForTarget } from "./local-credential.js";
|
||||||
|
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
||||||
@@ -25,8 +30,10 @@ async function expectWebSocketCloses(params: {
|
|||||||
protocol?: string;
|
protocol?: string;
|
||||||
code: number;
|
code: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
hello?: Record<string, unknown>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { ws } = await connectWebSocket(params);
|
const { ws } = await connectWebSocket(params);
|
||||||
|
if (params.hello) ws.send(JSON.stringify(params.hello));
|
||||||
await expect(
|
await expect(
|
||||||
new Promise<{ code: number; reason: string }>((resolve) => {
|
new Promise<{ code: number; reason: string }>((resolve) => {
|
||||||
ws.once("close", (code, reason) => {
|
ws.once("close", (code, reason) => {
|
||||||
@@ -77,6 +84,14 @@ describe("daemon bearer auth", () => {
|
|||||||
headers: { Authorization: "Bearer correct-password" },
|
headers: { Authorization: "Bearer correct-password" },
|
||||||
});
|
});
|
||||||
expect(correct.status).toBe(200);
|
expect(correct.status).toBe(200);
|
||||||
|
|
||||||
|
const localToken = (
|
||||||
|
await readFile(join(daemonHandle.paseoHome, "local-credential"), "utf8")
|
||||||
|
).trim();
|
||||||
|
const local = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, {
|
||||||
|
headers: { Authorization: `Bearer ${localToken}` },
|
||||||
|
});
|
||||||
|
expect(local.status).toBe(200);
|
||||||
} finally {
|
} finally {
|
||||||
await daemonHandle.close();
|
await daemonHandle.close();
|
||||||
}
|
}
|
||||||
@@ -133,6 +148,7 @@ describe("daemon bearer auth", () => {
|
|||||||
port: daemonHandle.port,
|
port: daemonHandle.port,
|
||||||
code: 4401,
|
code: 4401,
|
||||||
reason: "Password required",
|
reason: "Password required",
|
||||||
|
hello: { type: "hello", clientId: "missing", clientType: "cli", protocolVersion: 1 },
|
||||||
});
|
});
|
||||||
await expectWebSocketCloses({
|
await expectWebSocketCloses({
|
||||||
port: daemonHandle.port,
|
port: daemonHandle.port,
|
||||||
@@ -146,9 +162,269 @@ describe("daemon bearer auth", () => {
|
|||||||
protocol: "paseo.bearer.correct-password",
|
protocol: "paseo.bearer.correct-password",
|
||||||
});
|
});
|
||||||
expect(protocol).toBe("paseo.bearer.correct-password");
|
expect(protocol).toBe("paseo.bearer.correct-password");
|
||||||
|
const serverInfo = new Promise<unknown>((resolve) => {
|
||||||
|
ws.once("message", (data) => resolve(JSON.parse(data.toString())));
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
clientId: "legacy",
|
||||||
|
clientType: "cli",
|
||||||
|
protocolVersion: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(serverInfo).resolves.toMatchObject({
|
||||||
|
type: "session",
|
||||||
|
message: { payload: { status: "server_info" } },
|
||||||
|
});
|
||||||
ws.close();
|
ws.close();
|
||||||
} finally {
|
} finally {
|
||||||
await daemonHandle.close();
|
await daemonHandle.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts hello password and local credential and reports the negotiated protocol", async () => {
|
||||||
|
const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } });
|
||||||
|
try {
|
||||||
|
const token = (
|
||||||
|
await readFile(join(daemonHandle.paseoHome, "local-credential"), "utf8")
|
||||||
|
).trim();
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
expect((await stat(join(daemonHandle.paseoHome, "local-credential"))).mode & 0o777).toBe(
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const auth of [
|
||||||
|
{ kind: "password", password: "correct-password" },
|
||||||
|
{ kind: "localCredential", token },
|
||||||
|
]) {
|
||||||
|
const { ws } = await connectWebSocket({ port: daemonHandle.port });
|
||||||
|
const serverInfo = new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||||
|
ws.once("message", (data) =>
|
||||||
|
resolve(JSON.parse(data.toString()) as Record<string, unknown>),
|
||||||
|
);
|
||||||
|
ws.once("close", () => reject(new Error("closed before server_info")));
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
clientId: `test-${auth.kind}`,
|
||||||
|
clientType: "cli",
|
||||||
|
protocolVersion: 2,
|
||||||
|
auth,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(serverInfo).resolves.toMatchObject({
|
||||||
|
type: "session",
|
||||||
|
message: { payload: { status: "server_info", protocolVersion: 1 } },
|
||||||
|
});
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await daemonHandle.close();
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
readFile(join(daemonHandle.paseoHome, "local-credential"), "utf8"),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the per-run credential in memory after the file changes", async () => {
|
||||||
|
const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } });
|
||||||
|
try {
|
||||||
|
const path = join(daemonHandle.paseoHome, "local-credential");
|
||||||
|
const token = (await readFile(path, "utf8")).trim();
|
||||||
|
await writeFile(path, "stale-on-disk\n");
|
||||||
|
const { ws } = await connectWebSocket({ port: daemonHandle.port });
|
||||||
|
const serverInfo = new Promise<unknown>((resolve) => {
|
||||||
|
ws.once("message", (data) => resolve(JSON.parse(data.toString())));
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
clientId: "local-after-file-change",
|
||||||
|
clientType: "cli",
|
||||||
|
protocolVersion: 1,
|
||||||
|
auth: { kind: "localCredential", token },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(serverInfo).resolves.toMatchObject({
|
||||||
|
type: "session",
|
||||||
|
message: { payload: { status: "server_info" } },
|
||||||
|
});
|
||||||
|
ws.close();
|
||||||
|
} finally {
|
||||||
|
await daemonHandle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a desktop-style client connects to its password-protected local daemon without a saved password", async () => {
|
||||||
|
const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } });
|
||||||
|
const target = `127.0.0.1:${daemonHandle.port}`;
|
||||||
|
await writeFile(join(daemonHandle.paseoHome, "paseo.pid"), JSON.stringify({ listen: target }));
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://${target}/ws`,
|
||||||
|
clientId: "desktop-managed-test",
|
||||||
|
clientType: "browser",
|
||||||
|
localCredential: async () =>
|
||||||
|
readLocalCredentialForTarget(daemonHandle.paseoHome, target) ?? undefined,
|
||||||
|
webSocketFactory: (url, options) =>
|
||||||
|
new WebSocket(url, options?.protocols, { headers: options?.headers }),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
expect(client.getLastServerInfoMessage()?.serverId).toBe(daemonHandle.daemon.getServerId());
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await daemonHandle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a wrong hello password with a protocol frame before close", async () => {
|
||||||
|
const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } });
|
||||||
|
try {
|
||||||
|
const { ws } = await connectWebSocket({ port: daemonHandle.port });
|
||||||
|
const frames: unknown[] = [];
|
||||||
|
const closed = new Promise<{ code: number; reason: string }>((resolve) => {
|
||||||
|
ws.on("message", (data) => frames.push(JSON.parse(data.toString())));
|
||||||
|
ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() }));
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
clientId: "wrong",
|
||||||
|
clientType: "cli",
|
||||||
|
protocolVersion: 1,
|
||||||
|
auth: { kind: "password", password: "wrong" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(closed).resolves.toEqual({ code: 4401, reason: "Incorrect password" });
|
||||||
|
expect(frames).toEqual([
|
||||||
|
{ type: "hello.rejected", reason: "incorrect_password", accepts: ["password"] },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await daemonHandle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces a typed auth reason from a real client with the wrong password", async () => {
|
||||||
|
const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } });
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${daemonHandle.port}/ws`,
|
||||||
|
clientId: "typed-wrong-password",
|
||||||
|
clientType: "browser",
|
||||||
|
password: "wrong password",
|
||||||
|
webSocketFactory: (url, options) =>
|
||||||
|
new WebSocket(url, options?.protocols, { headers: options?.headers }),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await expect(client.connect()).rejects.toMatchObject({
|
||||||
|
name: "DaemonAuthenticationError",
|
||||||
|
reason: "incorrect_password",
|
||||||
|
} satisfies Partial<DaemonAuthenticationError>);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await daemonHandle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authenticates a real browser client when its password is invalid as a WebSocket subprotocol", async () => {
|
||||||
|
const password = "two words";
|
||||||
|
const daemonHandle = await createTestPaseoDaemon({
|
||||||
|
auth: { password: await hash(password, 4) },
|
||||||
|
});
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${daemonHandle.port}/ws`,
|
||||||
|
clientId: "browser-password-with-spaces",
|
||||||
|
clientType: "browser",
|
||||||
|
password,
|
||||||
|
webSocketFactory: (url, options) =>
|
||||||
|
new WebSocket(url, options?.protocols, { headers: options?.headers }),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
expect(client.getLastServerInfoMessage()?.serverId).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await daemonHandle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores a proxy Basic header and admits the hello password", async () => {
|
||||||
|
const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } });
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${daemonHandle.port}/ws`, undefined, {
|
||||||
|
headers: { Authorization: "Basic proxy-credential" },
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
ws.once("open", () => resolve());
|
||||||
|
ws.once("error", reject);
|
||||||
|
});
|
||||||
|
const info = new Promise<unknown>((resolve) => {
|
||||||
|
ws.once("message", (frame) => resolve(JSON.parse(frame.toString())));
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
clientId: "behind-basic-proxy",
|
||||||
|
clientType: "cli",
|
||||||
|
protocolVersion: 1,
|
||||||
|
auth: { kind: "password", password: "correct-password" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(info).resolves.toMatchObject({
|
||||||
|
type: "session",
|
||||||
|
message: { payload: { status: "server_info" } },
|
||||||
|
});
|
||||||
|
ws.close();
|
||||||
|
} finally {
|
||||||
|
await daemonHandle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends password_required to a capable client without a credential", async () => {
|
||||||
|
const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } });
|
||||||
|
try {
|
||||||
|
const { ws } = await connectWebSocket({ port: daemonHandle.port });
|
||||||
|
const frames: unknown[] = [];
|
||||||
|
const closed = new Promise<number>((resolve) => {
|
||||||
|
ws.on("message", (data) => frames.push(JSON.parse(data.toString())));
|
||||||
|
ws.once("close", (code) => resolve(code));
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
clientId: "capable",
|
||||||
|
clientType: "cli",
|
||||||
|
protocolVersion: 1,
|
||||||
|
capabilities: { hello_rejection: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(closed).resolves.toBe(4401);
|
||||||
|
expect(frames).toEqual([
|
||||||
|
{ type: "hello.rejected", reason: "password_required", accepts: ["password"] },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await daemonHandle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes a pre-hello ping without sending server data", async () => {
|
||||||
|
const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } });
|
||||||
|
try {
|
||||||
|
const { ws } = await connectWebSocket({ port: daemonHandle.port });
|
||||||
|
const frames: unknown[] = [];
|
||||||
|
const closed = new Promise<number>((resolve) => {
|
||||||
|
ws.on("message", (data) => frames.push(JSON.parse(data.toString())));
|
||||||
|
ws.once("close", (code) => resolve(code));
|
||||||
|
});
|
||||||
|
ws.send(JSON.stringify({ type: "ping" }));
|
||||||
|
await expect(closed).resolves.toBe(4002);
|
||||||
|
expect(frames).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await daemonHandle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ import {
|
|||||||
isAgentMcpRequestAuthorized,
|
isAgentMcpRequestAuthorized,
|
||||||
type DaemonAuthConfig,
|
type DaemonAuthConfig,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
|
import { deleteLocalCredential, writeLocalCredential } from "./local-credential.js";
|
||||||
import { createWebUiMiddleware } from "./web-ui.js";
|
import { createWebUiMiddleware } from "./web-ui.js";
|
||||||
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||||
import { createGitMutationService } from "./session/git-mutation/git-mutation-service.js";
|
import { createGitMutationService } from "./session/git-mutation/git-mutation-service.js";
|
||||||
@@ -764,8 +765,10 @@ export async function createPaseoDaemon(
|
|||||||
// remain protected.
|
// remain protected.
|
||||||
mountWebUi(app, config, logger);
|
mountWebUi(app, config, logger);
|
||||||
|
|
||||||
|
let localCredential: string | null = null;
|
||||||
|
const daemonAuth = { ...config.auth, localCredential: () => localCredential };
|
||||||
app.use(
|
app.use(
|
||||||
createRequireBearerMiddleware(config.auth, (context) => {
|
createRequireBearerMiddleware(daemonAuth, (context) => {
|
||||||
logger.warn(context, "Rejected HTTP request with invalid daemon password");
|
logger.warn(context, "Rejected HTTP request with invalid daemon password");
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1576,6 +1579,7 @@ export async function createPaseoDaemon(
|
|||||||
const start = async () => {
|
const start = async () => {
|
||||||
let mainStarted = false;
|
let mainStarted = false;
|
||||||
try {
|
try {
|
||||||
|
localCredential = await writeLocalCredential(config.paseoHome);
|
||||||
if (serviceProxyListenTarget) {
|
if (serviceProxyListenTarget) {
|
||||||
const boundServiceProxyTarget = await serviceProxy.startStandalone({
|
const boundServiceProxyTarget = await serviceProxy.startStandalone({
|
||||||
listenTarget: serviceProxyListenTarget,
|
listenTarget: serviceProxyListenTarget,
|
||||||
@@ -1669,7 +1673,7 @@ export async function createPaseoDaemon(
|
|||||||
startPaused: true,
|
startPaused: true,
|
||||||
},
|
},
|
||||||
workspaceAutoName,
|
workspaceAutoName,
|
||||||
config.auth,
|
daemonAuth,
|
||||||
speechService,
|
speechService,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
{
|
{
|
||||||
@@ -1766,6 +1770,8 @@ export async function createPaseoDaemon(
|
|||||||
speechService.start();
|
speechService.start();
|
||||||
scriptHealthMonitor.start();
|
scriptHealthMonitor.start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
localCredential = null;
|
||||||
|
await deleteLocalCredential(config.paseoHome);
|
||||||
unsubscribePluginProviders();
|
unsubscribePluginProviders();
|
||||||
await pluginRuntime.stopAllPlugins().catch(() => undefined);
|
await pluginRuntime.stopAllPlugins().catch(() => undefined);
|
||||||
await serviceProxy.stopStandalone().catch(() => undefined);
|
await serviceProxy.stopStandalone().catch(() => undefined);
|
||||||
@@ -1779,6 +1785,8 @@ export async function createPaseoDaemon(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
|
localCredential = null;
|
||||||
|
await deleteLocalCredential(config.paseoHome);
|
||||||
// Stop tracking plugin provider registrations before anything tears plugins
|
// Stop tracking plugin provider registrations before anything tears plugins
|
||||||
// down, so plugin shutdown cannot withdraw a provider from under an agent
|
// down, so plugin shutdown cannot withdraw a provider from under an agent
|
||||||
// that is still open. Plugins themselves are stopped once every session
|
// that is still open. Plugins themselves are stopped once every session
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { isBearerTokenValid } from "./auth.js";
|
import { isBearerTokenValidAsync } from "./auth.js";
|
||||||
|
|
||||||
const roots: string[] = [];
|
const roots: string[] = [];
|
||||||
const CONFIG_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
const CONFIG_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW";
|
||||||
@@ -34,9 +34,9 @@ describe("daemon auth config", () => {
|
|||||||
const config = loadConfig(paseoHome, { env: {} });
|
const config = loadConfig(paseoHome, { env: {} });
|
||||||
|
|
||||||
expect(config.auth?.password).toBe(CONFIG_PASSWORD_HASH);
|
expect(config.auth?.password).toBe(CONFIG_PASSWORD_HASH);
|
||||||
expect(isBearerTokenValid({ password: config.auth?.password, token: "correct-password" })).toBe(
|
expect(
|
||||||
true,
|
await isBearerTokenValidAsync({ password: config.auth?.password, token: "correct-password" }),
|
||||||
);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lets PASEO_PASSWORD override config.json auth password hash", async () => {
|
test("lets PASEO_PASSWORD override config.json auth password hash", async () => {
|
||||||
@@ -53,6 +53,8 @@ describe("daemon auth config", () => {
|
|||||||
|
|
||||||
expect(config.auth?.password).not.toBe(CONFIG_PASSWORD_HASH);
|
expect(config.auth?.password).not.toBe(CONFIG_PASSWORD_HASH);
|
||||||
expect(config.auth?.password).toMatch(/^\$2[aby]\$12\$/);
|
expect(config.auth?.password).toMatch(/^\$2[aby]\$12\$/);
|
||||||
expect(isBearerTokenValid({ password: config.auth?.password, token: "from-env" })).toBe(true);
|
expect(
|
||||||
|
await isBearerTokenValidAsync({ password: config.auth?.password, token: "from-env" }),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const killTree = (pid: number, signal: string): Promise<void> =>
|
|||||||
export { resolvePaseoHome } from "./paseo-home.js";
|
export { resolvePaseoHome } from "./paseo-home.js";
|
||||||
export { ensurePrivateDirectory } from "./private-files.js";
|
export { ensurePrivateDirectory } from "./private-files.js";
|
||||||
export { daemonLaunchEnvironment } from "./config-environment.js";
|
export { daemonLaunchEnvironment } from "./config-environment.js";
|
||||||
|
export { readLocalCredentialForTarget } from "./local-credential.js";
|
||||||
export {
|
export {
|
||||||
isSamePidLock as isSameDaemonInstance,
|
isSamePidLock as isSameDaemonInstance,
|
||||||
type PidLockInfo as DaemonInstance,
|
type PidLockInfo as DaemonInstance,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ test.each([
|
|||||||
["desktop daemon management", "../../../desktop/src/daemon/daemon-manager.ts"],
|
["desktop daemon management", "../../../desktop/src/daemon/daemon-manager.ts"],
|
||||||
["supervisor", "../../scripts/supervisor-entrypoint.ts"],
|
["supervisor", "../../scripts/supervisor-entrypoint.ts"],
|
||||||
["daemon control", "@getpaseo/server/daemon-control"],
|
["daemon control", "@getpaseo/server/daemon-control"],
|
||||||
["configuration", "@getpaseo/server/configuration"],
|
["configuration without the session admission resolver", "@getpaseo/server/configuration"],
|
||||||
])("%s does not load the daemon runtime or wire schemas", async (_name, entry) => {
|
])("%s does not load the daemon runtime or wire schemas", async (_name, entry) => {
|
||||||
const inputs = await runtimeDependencies(entry);
|
const inputs = await runtimeDependencies(entry);
|
||||||
expect(inputs.filter((file) => /\/(?:bootstrap|messages)\.[jt]s$/.test(file))).toEqual([]);
|
expect(inputs.filter((file) => /\/(?:bootstrap|messages)\.[jt]s$/.test(file))).toEqual([]);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export {
|
|||||||
savePersistedConfig,
|
savePersistedConfig,
|
||||||
type PersistedConfig,
|
type PersistedConfig,
|
||||||
} from "./persisted-config.js";
|
} from "./persisted-config.js";
|
||||||
export { hashDaemonPassword, isBearerTokenValid } from "./auth.js";
|
export { hashDaemonPassword, isBearerTokenValidAsync } from "./auth.js";
|
||||||
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
|
export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
|
||||||
export {
|
export {
|
||||||
ConnectionOfferSchema,
|
ConnectionOfferSchema,
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ test("Hub completes the standard hello before rejecting a second hello", async (
|
|||||||
expect(hub.probeTrustedHello()).toBe(4002);
|
expect(hub.probeTrustedHello()).toBe(4002);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("legacy Hub wire behavior still enters the common Session bootstrap", async () => {
|
test("legacy Hub wire behavior immediately enters the common Session bootstrap", async () => {
|
||||||
const launched = await HubRelationshipHarness.start();
|
const launched = await HubRelationshipHarness.start();
|
||||||
await launched.beginConnect().result;
|
await launched.beginConnect().result;
|
||||||
launched.connectLatestLegacySocket();
|
launched.connectLatestLegacySocket();
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { chmod, rename, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
normalizeLoopbackToLocalhost,
|
||||||
|
parseConnectionUri,
|
||||||
|
} from "@getpaseo/protocol/daemon-endpoints";
|
||||||
|
|
||||||
|
const FILE_NAME = "local-credential";
|
||||||
|
|
||||||
|
export async function writeLocalCredential(home: string): Promise<string> {
|
||||||
|
const token = randomBytes(32).toString("base64url");
|
||||||
|
const temporary = join(home, `${FILE_NAME}.${randomUUID()}.tmp`);
|
||||||
|
try {
|
||||||
|
await writeFile(temporary, `${token}\n`, { mode: 0o600, flag: "wx" });
|
||||||
|
await chmod(temporary, 0o600);
|
||||||
|
await rename(temporary, join(home, FILE_NAME));
|
||||||
|
return token;
|
||||||
|
} finally {
|
||||||
|
await unlink(temporary).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLocalCredential(home: string): Promise<void> {
|
||||||
|
await unlink(join(home, FILE_NAME)).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLocalCredential(home: string): string | null {
|
||||||
|
try {
|
||||||
|
const token = readFileSync(join(home, FILE_NAME), "utf8").trim();
|
||||||
|
return /^[A-Za-z0-9_-]{43}$/.test(token) ? token : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesLocalCredential(expected: string, candidate: string): boolean {
|
||||||
|
const actual = Buffer.from(candidate);
|
||||||
|
const reference = Buffer.from(expected);
|
||||||
|
return actual.length === reference.length && timingSafeEqual(actual, reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTarget(target: string): string | null {
|
||||||
|
try {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
if (trimmed.startsWith("tcp://")) {
|
||||||
|
const parsed = parseConnectionUri(trimmed);
|
||||||
|
const endpoint = parsed.isIpv6
|
||||||
|
? `[${parsed.host}]:${parsed.port}`
|
||||||
|
: `${parsed.host}:${parsed.port}`;
|
||||||
|
return normalizeLoopbackToLocalhost(endpoint);
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("unix://") || trimmed.startsWith("pipe://")) return trimmed;
|
||||||
|
if (trimmed.startsWith("/")) return `unix://${trimmed}`;
|
||||||
|
if (trimmed.startsWith("\\\\.\\pipe\\")) return `pipe://${trimmed}`;
|
||||||
|
if (/^\d+$/.test(trimmed)) return `localhost:${trimmed}`;
|
||||||
|
return normalizeLoopbackToLocalhost(trimmed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLocalCredentialForTarget(home: string, target: string): string | null {
|
||||||
|
try {
|
||||||
|
const lock = JSON.parse(readFileSync(join(home, "paseo.pid"), "utf8")) as {
|
||||||
|
listen?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof lock.listen !== "string") return null;
|
||||||
|
const selected = normalizeTarget(target);
|
||||||
|
const listening = normalizeTarget(lock.listen);
|
||||||
|
if (!selected || !listening || selected !== listening) return null;
|
||||||
|
return readLocalCredential(home);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { compare } from "bcryptjs";
|
||||||
|
import type { WSHelloMessage } from "@getpaseo/protocol/messages";
|
||||||
|
import { OWNER_PERMISSIONS } from "./authorization/index.js";
|
||||||
|
import { matchesLocalCredential } from "./local-credential.js";
|
||||||
|
import type { SessionAdmission } from "./websocket-server.js";
|
||||||
|
|
||||||
|
export type AdmissionFailure = "password_required" | "incorrect_password";
|
||||||
|
|
||||||
|
type AdmissionResolution = { admission: SessionAdmission } | { rejection: AdmissionFailure };
|
||||||
|
|
||||||
|
export async function resolveSessionAdmission(input: {
|
||||||
|
credential: WSHelloMessage["auth"];
|
||||||
|
passwordHash: string | undefined;
|
||||||
|
localCredential: string | null;
|
||||||
|
transport: "direct" | "relay";
|
||||||
|
}): Promise<AdmissionResolution> {
|
||||||
|
const { credential, passwordHash, localCredential, transport } = input;
|
||||||
|
if (!passwordHash) {
|
||||||
|
return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } };
|
||||||
|
}
|
||||||
|
if (!credential) {
|
||||||
|
// COMPAT(relayPasswordOptional): added in v0.9.1, remove once release N mobile builds are live on App Store and Play.
|
||||||
|
if (transport === "relay") {
|
||||||
|
return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } };
|
||||||
|
}
|
||||||
|
return { rejection: "password_required" };
|
||||||
|
}
|
||||||
|
if (credential.kind === "localCredential") {
|
||||||
|
if (localCredential && matchesLocalCredential(localCredential, credential.token)) {
|
||||||
|
return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } };
|
||||||
|
}
|
||||||
|
return { rejection: "incorrect_password" };
|
||||||
|
}
|
||||||
|
return (await compare(credential.password, passwordHash))
|
||||||
|
? { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } }
|
||||||
|
: { rejection: "incorrect_password" };
|
||||||
|
}
|
||||||
@@ -242,6 +242,7 @@ function createServer(options?: {
|
|||||||
speechReadiness?: SpeechReadinessSnapshot | null;
|
speechReadiness?: SpeechReadinessSnapshot | null;
|
||||||
logger?: ReturnType<typeof createLogger>;
|
logger?: ReturnType<typeof createLogger>;
|
||||||
startPaused?: boolean;
|
startPaused?: boolean;
|
||||||
|
auth?: { password: string; localCredential: () => string | null };
|
||||||
}) {
|
}) {
|
||||||
const speechReadiness = options?.speechReadiness ?? null;
|
const speechReadiness = options?.speechReadiness ?? null;
|
||||||
const daemonConfigStore = {
|
const daemonConfigStore = {
|
||||||
@@ -272,7 +273,7 @@ function createServer(options?: {
|
|||||||
null,
|
null,
|
||||||
{ allowedOrigins: new Set(), startPaused: options?.startPaused },
|
{ allowedOrigins: new Set(), startPaused: options?.startPaused },
|
||||||
createWorkspaceAutoNameStub(),
|
createWorkspaceAutoNameStub(),
|
||||||
undefined,
|
options?.auth,
|
||||||
speechReadiness
|
speechReadiness
|
||||||
? {
|
? {
|
||||||
resolveStt: () => null,
|
resolveStt: () => null,
|
||||||
@@ -426,7 +427,7 @@ async function attachRelayAndHello(params: {
|
|||||||
}) {
|
}) {
|
||||||
await params.server.attachExternalSocket(params.socket, { transport: "relay" });
|
await params.server.attachExternalSocket(params.socket, { transport: "relay" });
|
||||||
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
|
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
|
||||||
expect(params.socket.sent.length).toBeGreaterThan(0);
|
await vi.waitFor(() => expect(params.socket.sent.length).toBeGreaterThan(0));
|
||||||
const envelope = parseSentEnvelope(params.socket.sent[0]);
|
const envelope = parseSentEnvelope(params.socket.sent[0]);
|
||||||
expect(envelope.type).toBe("session");
|
expect(envelope.type).toBe("session");
|
||||||
const serverInfo = parseServerInfoStatusPayload(envelope.message?.payload);
|
const serverInfo = parseServerInfoStatusPayload(envelope.message?.payload);
|
||||||
@@ -445,7 +446,7 @@ async function attachDirectAndHello(params: {
|
|||||||
createDirectRequest(),
|
createDirectRequest(),
|
||||||
);
|
);
|
||||||
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
|
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
|
||||||
expect(params.socket.sent.length).toBeGreaterThan(0);
|
await vi.waitFor(() => expect(params.socket.sent.length).toBeGreaterThan(0));
|
||||||
const envelope = parseSentEnvelope(params.socket.sent[0]);
|
const envelope = parseSentEnvelope(params.socket.sent[0]);
|
||||||
expect(envelope.type).toBe("session");
|
expect(envelope.type).toBe("session");
|
||||||
const serverInfo = parseServerInfoStatusPayload(envelope.message?.payload);
|
const serverInfo = parseServerInfoStatusPayload(envelope.message?.payload);
|
||||||
@@ -485,6 +486,96 @@ function holdSessionCleanup(session: (typeof sessionMock.instances)[number]): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("relay external socket reconnect behavior", () => {
|
describe("relay external socket reconnect behavior", () => {
|
||||||
|
test("closes only a hello socket when post-admission setup throws", async () => {
|
||||||
|
const server = createServer();
|
||||||
|
class ThrowOnceSocket extends MockSocket {
|
||||||
|
private firstClose = true;
|
||||||
|
override close(code?: number, reason?: string): void {
|
||||||
|
if (this.firstClose) {
|
||||||
|
this.firstClose = false;
|
||||||
|
throw new Error("socket close failed during hello");
|
||||||
|
}
|
||||||
|
super.close(code, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const failed = new ThrowOnceSocket();
|
||||||
|
try {
|
||||||
|
await server.attachExternalSocket(failed, { transport: "relay" });
|
||||||
|
failed.emit("message", JSON.stringify(createHelloMessage("plugin:not-a-plugin")));
|
||||||
|
await vi.waitFor(() => expect(failed.readyState).toBe(3));
|
||||||
|
const initiallyFailed = new ThrowOnceSocket();
|
||||||
|
await server.attachExternalSocket(
|
||||||
|
initiallyFailed,
|
||||||
|
{ transport: "relay" },
|
||||||
|
null,
|
||||||
|
createHelloMessage("plugin:initially-invalid"),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(initiallyFailed.readyState).toBe(3));
|
||||||
|
const healthy = new MockSocket();
|
||||||
|
await attachRelayAndHello({ server, socket: healthy, clientId: "still-running" });
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not log malformed pre-admission credential frames", async () => {
|
||||||
|
const logger = createLogger();
|
||||||
|
const server = createServer({ logger });
|
||||||
|
const socket = new MockSocket();
|
||||||
|
try {
|
||||||
|
await server.attachExternalSocket(socket, { transport: "relay" });
|
||||||
|
socket.emit("message", '{"type":"hello","auth":{"kind":"password","password":"log-secret"},');
|
||||||
|
await vi.waitFor(() => expect(socket.readyState).toBe(3));
|
||||||
|
expect(JSON.stringify(logger.error.mock.calls)).not.toContain("log-secret");
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("admits a hello password and an old relay hello, but rejects a wrong password", async () => {
|
||||||
|
const server = createServer({
|
||||||
|
auth: {
|
||||||
|
password: "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW",
|
||||||
|
localCredential: () => "local-token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const passwordSocket = new MockSocket();
|
||||||
|
await server.attachExternalSocket(passwordSocket, { transport: "relay" });
|
||||||
|
passwordSocket.emit(
|
||||||
|
"message",
|
||||||
|
JSON.stringify({
|
||||||
|
...createHelloMessage("relay-password"),
|
||||||
|
auth: { kind: "password", password: "correct-password" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(sentServerInfoEnvelopes(passwordSocket)).toHaveLength(1));
|
||||||
|
|
||||||
|
const legacySocket = new MockSocket();
|
||||||
|
await server.attachExternalSocket(legacySocket, { transport: "relay" });
|
||||||
|
legacySocket.emit("message", JSON.stringify(createHelloMessage("relay-legacy")));
|
||||||
|
await vi.waitFor(() => expect(sentServerInfoEnvelopes(legacySocket)).toHaveLength(1));
|
||||||
|
|
||||||
|
const wrongSocket = new MockSocket();
|
||||||
|
await server.attachExternalSocket(wrongSocket, { transport: "relay" });
|
||||||
|
wrongSocket.emit(
|
||||||
|
"message",
|
||||||
|
JSON.stringify({
|
||||||
|
...createHelloMessage("relay-wrong"),
|
||||||
|
auth: { kind: "password", password: "wrong" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(wrongSocket.readyState).toBe(3));
|
||||||
|
expect(wrongSocket.sent).toContain(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "hello.rejected",
|
||||||
|
reason: "incorrect_password",
|
||||||
|
accepts: ["password"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionMock.instances.length = 0;
|
sessionMock.instances.length = 0;
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -535,7 +626,7 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
const secondAttachment = await server.attachPluginSocket("exclusive", secondSocket);
|
const secondAttachment = await server.attachPluginSocket("exclusive", secondSocket);
|
||||||
secondSocket.emit("message", JSON.stringify(createHelloMessage("plugin:exclusive")));
|
secondSocket.emit("message", JSON.stringify(createHelloMessage("plugin:exclusive")));
|
||||||
|
|
||||||
expect(sessionMock.instances).toHaveLength(2);
|
await vi.waitFor(() => expect(sessionMock.instances).toHaveLength(2));
|
||||||
firstSocket.emit("close", 1000, "plugin stopped");
|
firstSocket.emit("close", 1000, "plugin stopped");
|
||||||
await firstAttachment.closed;
|
await firstAttachment.closed;
|
||||||
expect(sessionMock.instances[0]?.cleanup).toHaveBeenCalledOnce();
|
expect(sessionMock.instances[0]?.cleanup).toHaveBeenCalledOnce();
|
||||||
@@ -552,6 +643,7 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
const socket = new MockSocket();
|
const socket = new MockSocket();
|
||||||
await server.attachPluginSocket("stalled", socket);
|
await server.attachPluginSocket("stalled", socket);
|
||||||
socket.emit("message", JSON.stringify(createHelloMessage("plugin:stalled")));
|
socket.emit("message", JSON.stringify(createHelloMessage("plugin:stalled")));
|
||||||
|
await vi.waitFor(() => expect(sentServerInfoEnvelopes(socket)).toHaveLength(1));
|
||||||
socket.emit("message", JSON.stringify({ type: "ping" }));
|
socket.emit("message", JSON.stringify({ type: "ping" }));
|
||||||
|
|
||||||
// Event loop starved past the lease: no further ping arrives.
|
// Event loop starved past the lease: no further ping arrives.
|
||||||
@@ -579,7 +671,7 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
await server.attachExternalSocket(socket, { transport: "relay" });
|
await server.attachExternalSocket(socket, { transport: "relay" });
|
||||||
socket.emit("message", JSON.stringify(createHelloMessage("plugin:not-a-plugin")));
|
socket.emit("message", JSON.stringify(createHelloMessage("plugin:not-a-plugin")));
|
||||||
|
|
||||||
expect(socket.readyState).toBe(3);
|
await vi.waitFor(() => expect(socket.readyState).toBe(3));
|
||||||
expect(sessionMock.instances).toHaveLength(0);
|
expect(sessionMock.instances).toHaveLength(0);
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
@@ -597,7 +689,7 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(sessionMock.instances).toHaveLength(1);
|
await vi.waitFor(() => expect(sessionMock.instances).toHaveLength(1));
|
||||||
const session = sessionMock.instances[0];
|
const session = sessionMock.instances[0];
|
||||||
expect(session.args.clientCapabilities).toEqual({
|
expect(session.args.clientCapabilities).toEqual({
|
||||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||||
@@ -645,7 +737,7 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
const pluginSocket = new MockSocket();
|
const pluginSocket = new MockSocket();
|
||||||
const attachment = await server.attachPluginSocket("startup", pluginSocket);
|
const attachment = await server.attachPluginSocket("startup", pluginSocket);
|
||||||
pluginSocket.emit("message", JSON.stringify(createHelloMessage("plugin:startup")));
|
pluginSocket.emit("message", JSON.stringify(createHelloMessage("plugin:startup")));
|
||||||
expect(sessionMock.instances).toHaveLength(1);
|
await vi.waitFor(() => expect(sessionMock.instances).toHaveLength(1));
|
||||||
|
|
||||||
server.beginAcceptingConnections();
|
server.beginAcceptingConnections();
|
||||||
const readySocket = new MockSocket();
|
const readySocket = new MockSocket();
|
||||||
@@ -736,6 +828,7 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
{ principalId: "hub:daemon-1", permissions: ["hub.execute"] },
|
{ principalId: "hub:daemon-1", permissions: ["hub.execute"] },
|
||||||
);
|
);
|
||||||
hubSocket.emit("message", JSON.stringify(createHelloMessage(clientId)));
|
hubSocket.emit("message", JSON.stringify(createHelloMessage(clientId)));
|
||||||
|
await vi.waitFor(() => expect(hubSocket.sent.length).toBeGreaterThan(0));
|
||||||
const hubEnvelope = parseSentEnvelope(hubSocket.sent[0]);
|
const hubEnvelope = parseSentEnvelope(hubSocket.sent[0]);
|
||||||
const hubInfo = parseServerInfoStatusPayload(hubEnvelope.message?.payload);
|
const hubInfo = parseServerInfoStatusPayload(hubEnvelope.message?.payload);
|
||||||
|
|
||||||
@@ -782,6 +875,7 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
relayConnectionId: "relay-conn-1",
|
relayConnectionId: "relay-conn-1",
|
||||||
});
|
});
|
||||||
socket.emit("message", JSON.stringify(createHelloMessage("cid-control-log")));
|
socket.emit("message", JSON.stringify(createHelloMessage("cid-control-log")));
|
||||||
|
await vi.waitFor(() => expect(sentServerInfoEnvelopes(socket)).toHaveLength(1));
|
||||||
socket.emit(
|
socket.emit(
|
||||||
"message",
|
"message",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -78,11 +78,13 @@ import {
|
|||||||
import { createGitHubService } from "../services/github-service.js";
|
import { createGitHubService } from "../services/github-service.js";
|
||||||
import type { ForgeService } from "../services/forge-service.js";
|
import type { ForgeService } from "../services/forge-service.js";
|
||||||
import {
|
import {
|
||||||
|
extractHttpBearerToken,
|
||||||
extractWsBearerProtocol,
|
extractWsBearerProtocol,
|
||||||
extractWsBearerToken,
|
extractWsBearerToken,
|
||||||
isBearerTokenValid,
|
isBearerTokenValidAsync,
|
||||||
type DaemonAuthConfig,
|
type DaemonAuthConfig,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
|
import { resolveSessionAdmission } from "./session-admission-auth.js";
|
||||||
import {
|
import {
|
||||||
WebSocketRuntimeMetricsWindow,
|
WebSocketRuntimeMetricsWindow,
|
||||||
type WebSocketRuntimeCounters,
|
type WebSocketRuntimeCounters,
|
||||||
@@ -130,7 +132,8 @@ interface PendingConnection {
|
|||||||
connectionLogger: pino.Logger;
|
connectionLogger: pino.Logger;
|
||||||
helloTimeout: ReturnType<typeof setTimeout> | null;
|
helloTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
identity: WebSocketConnectionIdentity;
|
identity: WebSocketConnectionIdentity;
|
||||||
admission: SessionAdmission;
|
admission: SessionAdmission | null;
|
||||||
|
authenticating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebSocketConnectionIdentity {
|
interface WebSocketConnectionIdentity {
|
||||||
@@ -545,6 +548,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private readonly workspaceAutoName: WorkspaceAutoName;
|
private readonly workspaceAutoName: WorkspaceAutoName;
|
||||||
private readonly downloadTokenStore: DownloadTokenStore;
|
private readonly downloadTokenStore: DownloadTokenStore;
|
||||||
private readonly paseoHome: string;
|
private readonly paseoHome: string;
|
||||||
|
private readonly passwordHash: string | undefined;
|
||||||
|
private readonly credentialSource: DaemonAuthConfig | undefined;
|
||||||
private readonly worktreesRoot: string | undefined;
|
private readonly worktreesRoot: string | undefined;
|
||||||
private readonly daemonConfigStore: DaemonConfigStore;
|
private readonly daemonConfigStore: DaemonConfigStore;
|
||||||
private readonly pushNotifications: PushNotifications;
|
private readonly pushNotifications: PushNotifications;
|
||||||
@@ -666,6 +671,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
throw new MissingDaemonVersionError();
|
throw new MissingDaemonVersionError();
|
||||||
}
|
}
|
||||||
this.daemonVersion = daemonVersion.trim();
|
this.daemonVersion = daemonVersion.trim();
|
||||||
|
this.credentialSource = auth;
|
||||||
this.daemonRuntimeConfig = daemonRuntimeConfig;
|
this.daemonRuntimeConfig = daemonRuntimeConfig;
|
||||||
this.browserToolsBroker = browserToolsBroker ?? null;
|
this.browserToolsBroker = browserToolsBroker ?? null;
|
||||||
this.hubRelationships = hubRelationships ?? null;
|
this.hubRelationships = hubRelationships ?? null;
|
||||||
@@ -694,6 +700,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.workspaceAutoName = workspaceAutoName;
|
this.workspaceAutoName = workspaceAutoName;
|
||||||
this.downloadTokenStore = downloadTokenStore;
|
this.downloadTokenStore = downloadTokenStore;
|
||||||
this.paseoHome = paseoHome;
|
this.paseoHome = paseoHome;
|
||||||
|
this.passwordHash = auth?.password;
|
||||||
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
|
this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
|
||||||
this.daemonConfigStore = daemonConfigStore;
|
this.daemonConfigStore = daemonConfigStore;
|
||||||
this.mcpBaseUrl = mcpBaseUrl;
|
this.mcpBaseUrl = mcpBaseUrl;
|
||||||
@@ -914,23 +921,39 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
request: IncomingMessage,
|
request: IncomingMessage,
|
||||||
password: string | undefined,
|
password: string | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (password) {
|
// Header validation is asynchronous. Buffer frames until the socket has a
|
||||||
const requestMetadata = extractSocketRequestMetadata(request);
|
// pending hello handler so an eager client cannot lose its first message.
|
||||||
|
ws.pause();
|
||||||
|
try {
|
||||||
|
// COMPAT(headerAuth): added in v0.9.1, remove after 2027-03-24.
|
||||||
const protocol = extractWsBearerProtocol(request.headers["sec-websocket-protocol"]);
|
const protocol = extractWsBearerProtocol(request.headers["sec-websocket-protocol"]);
|
||||||
const token = extractWsBearerToken(protocol);
|
const token =
|
||||||
const isAuthorized = isBearerTokenValid({ password, token });
|
extractHttpBearerToken(request.headers.authorization) ?? extractWsBearerToken(protocol);
|
||||||
if (!isAuthorized) {
|
const hasHeaderCredential = token !== null;
|
||||||
const reason = token === null ? "Password required" : "Incorrect password";
|
if (password && hasHeaderCredential) {
|
||||||
this.logger.warn(
|
const requestMetadata = extractSocketRequestMetadata(request);
|
||||||
{ ...requestMetadata, hasToken: token !== null },
|
const isAuthorized = await isBearerTokenValidAsync({ password, token });
|
||||||
"Rejected WebSocket connection with invalid daemon password",
|
if (!isAuthorized) {
|
||||||
);
|
const reason = "Incorrect password";
|
||||||
ws.close(WS_CLOSE_DAEMON_AUTH_FAILED, reason);
|
this.logger.warn(
|
||||||
return;
|
{ ...requestMetadata, hasToken: true },
|
||||||
|
"Rejected WebSocket connection with invalid daemon password",
|
||||||
|
);
|
||||||
|
ws.close(WS_CLOSE_DAEMON_AUTH_FAILED, reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await this.attachSocket(ws, request);
|
await this.attachSocket(
|
||||||
|
ws,
|
||||||
|
request,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
hasHeaderCredential ? OWNER_SESSION_ADMISSION : null,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
ws.resume();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public broadcast(message: WSOutboundMessage): void {
|
public broadcast(message: WSOutboundMessage): void {
|
||||||
@@ -977,7 +1000,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
public async attachExternalSocket(
|
public async attachExternalSocket(
|
||||||
ws: WebSocketLike,
|
ws: WebSocketLike,
|
||||||
metadata?: ExternalSocketMetadata,
|
metadata?: ExternalSocketMetadata,
|
||||||
admission: SessionAdmission = OWNER_SESSION_ADMISSION,
|
admission: SessionAdmission | null = null,
|
||||||
initialHello?: WSHelloMessage,
|
initialHello?: WSHelloMessage,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (metadata?.transport === "relay") {
|
if (metadata?.transport === "relay") {
|
||||||
@@ -1000,7 +1023,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.pluginSocketIds.set(ws, pluginId);
|
this.pluginSocketIds.set(ws, pluginId);
|
||||||
this.pluginSocketCleanup.set(ws, resolve);
|
this.pluginSocketCleanup.set(ws, resolve);
|
||||||
try {
|
try {
|
||||||
await this.attachSocket(ws, undefined, undefined, true);
|
await this.attachSocket(ws, undefined, undefined, true, OWNER_SESSION_ADMISSION);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.pluginSocketIds.delete(ws);
|
this.pluginSocketIds.delete(ws);
|
||||||
this.finishPluginSocketCleanup(ws);
|
this.finishPluginSocketCleanup(ws);
|
||||||
@@ -1014,7 +1037,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
permissions: readonly DaemonPermission[],
|
permissions: readonly DaemonPermission[],
|
||||||
): void {
|
): void {
|
||||||
for (const pending of this.pendingConnections.values()) {
|
for (const pending of this.pendingConnections.values()) {
|
||||||
if (pending.admission.principalId === principalId) {
|
if (pending.admission?.principalId === principalId) {
|
||||||
pending.admission = { ...pending.admission, permissions };
|
pending.admission = { ...pending.admission, permissions };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1282,7 +1305,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
request?: unknown,
|
request?: unknown,
|
||||||
metadata?: ExternalSocketMetadata,
|
metadata?: ExternalSocketMetadata,
|
||||||
allowDuringStartup = false,
|
allowDuringStartup = false,
|
||||||
admission: SessionAdmission = OWNER_SESSION_ADMISSION,
|
admission: SessionAdmission | null = null,
|
||||||
initialHello?: WSHelloMessage,
|
initialHello?: WSHelloMessage,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
if (
|
||||||
@@ -1307,6 +1330,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
helloTimeout: null,
|
helloTimeout: null,
|
||||||
identity,
|
identity,
|
||||||
admission,
|
admission,
|
||||||
|
authenticating: false,
|
||||||
};
|
};
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (this.pendingConnections.get(ws) !== pending) {
|
if (this.pendingConnections.get(ws) !== pending) {
|
||||||
@@ -1338,9 +1362,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
},
|
},
|
||||||
"Client connected; awaiting hello",
|
"Client connected; awaiting hello",
|
||||||
);
|
);
|
||||||
if (initialHello) {
|
if (initialHello) await this.handleHelloSafely({ ws, message: initialHello, pending });
|
||||||
this.handleHello({ ws, message: initialHello, pending });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private createSessionConnection(params: {
|
private createSessionConnection(params: {
|
||||||
@@ -1535,14 +1557,14 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleHello(params: {
|
private async handleHello(params: {
|
||||||
ws: WebSocketLike;
|
ws: WebSocketLike;
|
||||||
message: WSHelloMessage;
|
message: WSHelloMessage;
|
||||||
pending: PendingConnection;
|
pending: PendingConnection;
|
||||||
}): void {
|
}): Promise<void> {
|
||||||
const { ws, message, pending } = params;
|
const { ws, message, pending } = params;
|
||||||
|
|
||||||
if (message.protocolVersion !== WS_PROTOCOL_VERSION) {
|
if (message.protocolVersion < 1) {
|
||||||
this.clearPendingConnection(ws);
|
this.clearPendingConnection(ws);
|
||||||
pending.connectionLogger.warn(
|
pending.connectionLogger.warn(
|
||||||
{
|
{
|
||||||
@@ -1551,14 +1573,13 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
},
|
},
|
||||||
"Rejected hello due to protocol version mismatch",
|
"Rejected hello due to protocol version mismatch",
|
||||||
);
|
);
|
||||||
try {
|
await this.rejectHello(ws, message, "incompatible_protocol");
|
||||||
ws.close(WS_CLOSE_INCOMPATIBLE_PROTOCOL, "Incompatible protocol version");
|
|
||||||
} catch {
|
|
||||||
// ignore close errors
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pending.authenticating = true;
|
||||||
|
if (!pending.admission && !(await this.admitPendingHello(ws, message, pending))) return;
|
||||||
|
|
||||||
const clientId = message.clientId.trim();
|
const clientId = message.clientId.trim();
|
||||||
if (clientId.length === 0) {
|
if (clientId.length === 0) {
|
||||||
this.clearPendingConnection(ws);
|
this.clearPendingConnection(ws);
|
||||||
@@ -1584,11 +1605,13 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.clearPendingConnection(ws);
|
this.clearPendingConnection(ws);
|
||||||
|
const admitted = pending.admission;
|
||||||
|
if (!admitted) throw new Error("Admitted hello has no session admission");
|
||||||
pending.identity.clientId = clientId;
|
pending.identity.clientId = clientId;
|
||||||
if (message.appVersion) {
|
if (message.appVersion) {
|
||||||
pending.identity.appVersion = message.appVersion;
|
pending.identity.appVersion = message.appVersion;
|
||||||
}
|
}
|
||||||
const sessionKey = sessionConnectionKey(pending.admission.principalId, clientId);
|
const sessionKey = sessionConnectionKey(admitted.principalId, clientId);
|
||||||
const existing = pluginId ? undefined : this.externalSessionsByKey.get(sessionKey);
|
const existing = pluginId ? undefined : this.externalSessionsByKey.get(sessionKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.resumeSession({ ws, message, pending, existing });
|
this.resumeSession({ ws, message, pending, existing });
|
||||||
@@ -1604,7 +1627,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
clientCapabilities: message.capabilities ?? null,
|
clientCapabilities: message.capabilities ?? null,
|
||||||
connectionLogger,
|
connectionLogger,
|
||||||
lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" },
|
lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" },
|
||||||
admission: pending.admission,
|
admission: admitted,
|
||||||
});
|
});
|
||||||
this.sessions.set(ws, connection);
|
this.sessions.set(ws, connection);
|
||||||
if (connection.lifecycle === "reconnectable") {
|
if (connection.lifecycle === "reconnectable") {
|
||||||
@@ -1622,6 +1645,93 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleHelloSafely(params: {
|
||||||
|
ws: WebSocketLike;
|
||||||
|
message: WSHelloMessage;
|
||||||
|
pending: PendingConnection;
|
||||||
|
}): Promise<void> {
|
||||||
|
return this.handleHello(params).catch((error: unknown) => {
|
||||||
|
try {
|
||||||
|
this.handleRawMessageError({
|
||||||
|
ws: params.ws,
|
||||||
|
data: "",
|
||||||
|
error,
|
||||||
|
log: params.pending.connectionLogger,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// The error reporter must not turn a connection failure into a process failure.
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
params.ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello");
|
||||||
|
} catch {
|
||||||
|
// The transport may already be closed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async admitPendingHello(
|
||||||
|
ws: WebSocketLike,
|
||||||
|
message: WSHelloMessage,
|
||||||
|
pending: PendingConnection,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (pending.admission) return true;
|
||||||
|
try {
|
||||||
|
const resolved = await resolveSessionAdmission({
|
||||||
|
credential: message.auth,
|
||||||
|
passwordHash: this.passwordHash,
|
||||||
|
localCredential: this.credentialSource?.localCredential?.() ?? null,
|
||||||
|
transport: pending.identity.transport === "relay" ? "relay" : "direct",
|
||||||
|
});
|
||||||
|
if (this.pendingConnections.get(ws) !== pending) return false;
|
||||||
|
if ("rejection" in resolved) {
|
||||||
|
this.clearPendingConnection(ws);
|
||||||
|
await this.rejectHello(ws, message, resolved.rejection);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pending.admission = resolved.admission;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
pending.connectionLogger.error({ err: error }, "Failed to resolve hello credential");
|
||||||
|
if (this.pendingConnections.get(ws) === pending) {
|
||||||
|
this.clearPendingConnection(ws);
|
||||||
|
await this.rejectHello(ws, message, "incorrect_password");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rejectHello(
|
||||||
|
ws: WebSocketLike,
|
||||||
|
hello: WSHelloMessage,
|
||||||
|
reason: "password_required" | "incorrect_password" | "incompatible_protocol",
|
||||||
|
): Promise<void> {
|
||||||
|
// Older clients discard unknown frames, then use the close code and reason.
|
||||||
|
// They never send hello.auth, so avoid sending an unknown envelope to them.
|
||||||
|
if (hello.auth || hello.capabilities?.[CLIENT_CAPS.helloRejection] === true) {
|
||||||
|
try {
|
||||||
|
await ws.send(JSON.stringify({ type: "hello.rejected", reason, accepts: ["password"] }));
|
||||||
|
} catch {
|
||||||
|
// The close reason remains the compatibility signal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const closeReason = {
|
||||||
|
password_required: "Password required",
|
||||||
|
incorrect_password: "Incorrect password",
|
||||||
|
incompatible_protocol: "Incompatible protocol version",
|
||||||
|
}[reason];
|
||||||
|
try {
|
||||||
|
ws.close(
|
||||||
|
reason === "incompatible_protocol"
|
||||||
|
? WS_CLOSE_INCOMPATIBLE_PROTOCOL
|
||||||
|
: WS_CLOSE_DAEMON_AUTH_FAILED,
|
||||||
|
closeReason,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore a transport that closed while the rejection was sent.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private resumeSession(params: {
|
private resumeSession(params: {
|
||||||
ws: WebSocketLike;
|
ws: WebSocketLike;
|
||||||
message: WSHelloMessage;
|
message: WSHelloMessage;
|
||||||
@@ -1667,6 +1777,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload {
|
private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload {
|
||||||
return {
|
return {
|
||||||
status: "server_info",
|
status: "server_info",
|
||||||
|
protocolVersion: WS_PROTOCOL_VERSION,
|
||||||
serverId: this.serverId,
|
serverId: this.serverId,
|
||||||
hostname: getHostname(),
|
hostname: getHostname(),
|
||||||
version: this.daemonVersion,
|
version: this.daemonVersion,
|
||||||
@@ -2134,8 +2245,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
pendingConnection: PendingConnection;
|
pendingConnection: PendingConnection;
|
||||||
}): void {
|
}): void {
|
||||||
const { ws, message, pendingConnection } = params;
|
const { ws, message, pendingConnection } = params;
|
||||||
if (message.type === "hello") {
|
if (message.type === "hello" && !pendingConnection.authenticating) {
|
||||||
this.handleHello({
|
void this.handleHelloSafely({
|
||||||
ws,
|
ws,
|
||||||
message,
|
message,
|
||||||
pending: pendingConnection,
|
pending: pendingConnection,
|
||||||
@@ -2205,7 +2316,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
const message = parsedMessage.data;
|
const message = parsedMessage.data;
|
||||||
this.recordInboundMessageType(message.type);
|
this.recordInboundMessageType(message.type);
|
||||||
|
|
||||||
if (message.type === "ping") {
|
if (message.type === "ping" && activeConnection) {
|
||||||
// A plugin socket is IPC to a child this daemon already supervises, not
|
// A plugin socket is IPC to a child this daemon already supervises, not
|
||||||
// an abandonable application socket.
|
// an abandonable application socket.
|
||||||
if (!this.pluginSocketIds.has(ws)) {
|
if (!this.pluginSocketIds.has(ws)) {
|
||||||
@@ -2215,10 +2326,6 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === "recording_state") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingConnection) {
|
if (pendingConnection) {
|
||||||
this.handlePendingConnectionMessage({
|
this.handlePendingConnectionMessage({
|
||||||
ws,
|
ws,
|
||||||
@@ -2228,6 +2335,10 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === "recording_state") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeConnection) {
|
if (!activeConnection) {
|
||||||
this.incrementRuntimeCounter("missingConnectionForMessage");
|
this.incrementRuntimeCounter("missingConnectionForMessage");
|
||||||
this.logger.error("No connection found for websocket");
|
this.logger.error("No connection found for websocket");
|
||||||
@@ -2303,21 +2414,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
}): void {
|
}): void {
|
||||||
const { ws, data, error, log } = params;
|
const { ws, data, error, log } = params;
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
const { rawPayload, parsedPayload } = this.decodeRawMessagePayloadForError(data);
|
// Inbound frames can contain hello.auth.password or session secrets.
|
||||||
|
log.error({ errorName: err.name }, "Failed to parse/handle message");
|
||||||
const trimmedRawPayload =
|
|
||||||
typeof rawPayload === "string" && rawPayload.length > 2000
|
|
||||||
? `${rawPayload.slice(0, 2000)}... (truncated)`
|
|
||||||
: rawPayload;
|
|
||||||
|
|
||||||
log.error(
|
|
||||||
{
|
|
||||||
err,
|
|
||||||
rawPayload: trimmedRawPayload,
|
|
||||||
parsedPayload,
|
|
||||||
},
|
|
||||||
"Failed to parse/handle message",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.pendingConnections.has(ws)) {
|
if (this.pendingConnections.has(ws)) {
|
||||||
this.clearPendingConnection(ws);
|
this.clearPendingConnection(ws);
|
||||||
@@ -2329,7 +2427,9 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload);
|
const requestInfo = extractRequestInfoFromUnknownWsInbound(
|
||||||
|
this.decodeRawMessagePayloadForError(data),
|
||||||
|
);
|
||||||
this.sessions.get(ws)?.session.delivery.protocolFailure(ws, {
|
this.sessions.get(ws)?.session.delivery.protocolFailure(ws, {
|
||||||
...requestInfo,
|
...requestInfo,
|
||||||
error: `Invalid message: ${err.message}`,
|
error: `Invalid message: ${err.message}`,
|
||||||
@@ -2337,24 +2437,12 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeRawMessagePayloadForError(data: Buffer | ArrayBuffer | Buffer[] | string): {
|
private decodeRawMessagePayloadForError(data: Buffer | ArrayBuffer | Buffer[] | string): unknown {
|
||||||
rawPayload: string | null;
|
|
||||||
parsedPayload: unknown;
|
|
||||||
} {
|
|
||||||
let rawPayload: string | null = null;
|
|
||||||
let parsedPayload: unknown = null;
|
|
||||||
try {
|
try {
|
||||||
const buffer = bufferFromWsData(data);
|
return JSON.parse(bufferFromWsData(data).toString());
|
||||||
rawPayload = buffer.toString();
|
} catch {
|
||||||
parsedPayload = JSON.parse(rawPayload);
|
return null;
|
||||||
} catch (payloadError) {
|
|
||||||
rawPayload = rawPayload ?? "<unreadable>";
|
|
||||||
parsedPayload = parsedPayload ?? rawPayload;
|
|
||||||
const payloadErr =
|
|
||||||
payloadError instanceof Error ? payloadError : new Error(String(payloadError));
|
|
||||||
this.logger.error({ err: payloadErr }, "Failed to decode raw payload");
|
|
||||||
}
|
}
|
||||||
return { rawPayload, parsedPayload };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void {
|
private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user