diff --git a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx
index cf1e121f93..8bb2fe767e 100644
--- a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx
+++ b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx
@@ -9,6 +9,7 @@ import {
waitFor,
} from "@testing-library/react";
import type { Host } from "@bb/domain";
+import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BbHttpError, sdk } from "@/lib/sdk";
import { hostsQueryKey } from "@/hooks/queries/query-keys";
@@ -76,11 +77,13 @@ describe("AddMachineDialog", () => {
const { queryClient, wrapper } = createQueryClientTestHarness();
render(
- ,
+
+
+ ,
{ wrapper },
);
@@ -155,11 +158,13 @@ describe("AddMachineDialog", () => {
const { queryClient, wrapper } = createQueryClientTestHarness();
render(
- ,
+
+
+ ,
{ wrapper },
);
@@ -191,4 +196,89 @@ describe("AddMachineDialog", () => {
).toBeDefined();
expect(screen.queryByText("dev-vm connected")).toBeNull();
});
+
+ it("explains that a loopback server is unreachable when connect is unpaired", async () => {
+ vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({
+ joinCode: "jc_test123",
+ hostId: "host_new",
+ expiresAt: Date.now() + 15 * 60 * 1000,
+ });
+ vi.mocked(sdk.plugins.callRpc).mockRejectedValue(
+ new BbHttpError({
+ body: {
+ ok: false,
+ error: { code: "handler_error", message: "not_paired" },
+ },
+ code: "handler_error",
+ message: "not_paired",
+ status: 500,
+ }),
+ );
+ vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]);
+
+ const { wrapper } = createQueryClientTestHarness();
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ // The desktop server listens on loopback only. Another machine cannot
+ // reach it, so a curl command against 127.0.0.1 can never work.
+ const notice = await screen.findByRole("status");
+ expect(notice.textContent).toContain(
+ "Another machine cannot use this address.",
+ );
+ expect(notice.textContent).toContain("http://127.0.0.1:38886");
+ expect(screen.queryByText(/--join-code jc_test123/)).toBeNull();
+ const link = screen.getByRole("link", { name: "Set up remote access" });
+ expect(link.getAttribute("href")).toBe("/settings/plugins/connect");
+ expect(
+ screen.queryByText("Waiting for the machine to connect…"),
+ ).toBeNull();
+ });
+
+ it("offers a retry when connect is temporarily unavailable on a loopback server", async () => {
+ vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({
+ joinCode: "jc_test123",
+ hostId: "host_new",
+ expiresAt: Date.now() + 15 * 60 * 1000,
+ });
+ vi.mocked(sdk.plugins.callRpc).mockRejectedValue(
+ new BbHttpError({
+ body: { error: "plugin starting" },
+ code: "unavailable",
+ message: "unavailable",
+ status: 503,
+ }),
+ );
+ vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]);
+
+ const { wrapper } = createQueryClientTestHarness();
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ // A 503 says nothing about pairing. Do not print a command that dials the
+ // new machine itself, and do not claim connect is unpaired: let the user
+ // retry.
+ expect(
+ await screen.findByText("Remote access isn't ready yet."),
+ ).toBeDefined();
+ expect(screen.getByRole("button", { name: "Try again" })).toBeDefined();
+ expect(screen.queryByText(/--join-code jc_test123/)).toBeNull();
+ expect(screen.queryByRole("status")).toBeNull();
+ });
});
diff --git a/apps/app/src/components/dialogs/AddMachineDialog.tsx b/apps/app/src/components/dialogs/AddMachineDialog.tsx
index 2aaed3a8db..eec88ad26e 100644
--- a/apps/app/src/components/dialogs/AddMachineDialog.tsx
+++ b/apps/app/src/components/dialogs/AddMachineDialog.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
+import { Link } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import type { Host } from "@bb/domain";
import { z } from "zod";
@@ -15,6 +16,8 @@ import { Icon } from "@bb/shared-ui/icon";
import { MachineStatusDot } from "@/components/machines/MachineStatusDot";
import { useHosts } from "@/hooks/queries/host-queries";
import { useClipboardCopy } from "@/lib/clipboard";
+import { isLocalOnlyUrl } from "@/lib/loopback-hostname";
+import { getPluginConfigurationRoutePath } from "@/lib/route-paths";
import { BbHttpError, sdk } from "@/lib/sdk";
import { getMutationErrorMessage } from "@/lib/mutation-errors";
@@ -41,24 +44,39 @@ function isNotPairedRpcError(error: BbHttpError): boolean {
return envelope.success && envelope.data.error.message === "not_paired";
}
-async function createConnectMachineCode(): Promise {
+/**
+ * Outcome of asking the connect plugin for a machine code.
+ * - `issued`: connect is paired; the command routes through getbb.app.
+ * - `unpaired`: connect is installed but not paired (or not installed at all).
+ * Only a direct server URL can work.
+ * - `unavailable`: a temporary failure (for example the plugin is still
+ * starting). Nothing is known about pairing.
+ */
+type ConnectMachineCodeResult =
+ | { kind: "issued"; code: ConnectMachineCode }
+ | { kind: "unpaired" }
+ | { kind: "unavailable" };
+
+async function createConnectMachineCode(): Promise {
try {
- return await sdk.plugins.callRpc({
+ const code = await sdk.plugins.callRpc({
pluginId: "connect",
method: "createMachineCode",
input: null,
outputSchema: connectMachineCodeSchema,
});
+ return { kind: "issued", code };
} catch (error) {
+ if (!(error instanceof BbHttpError)) throw error;
if (
- error instanceof BbHttpError &&
- (error.code === "not_paired" ||
- isNotPairedRpcError(error) ||
- error.status === 404 ||
- error.status === 422 ||
- error.status === 503)
+ error.code === "not_paired" ||
+ isNotPairedRpcError(error) ||
+ error.status === 404
) {
- return null;
+ return { kind: "unpaired" };
+ }
+ if (error.status === 422 || error.status === 503) {
+ return { kind: "unavailable" };
}
throw error;
}
@@ -120,6 +138,54 @@ function pairingCommand(
return `curl -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 ${serverUrl}/install.sh | sh -s -- --join-code ${joinCode} --host-id ${hostId} --server ${serverUrl}${machineFlag}`;
}
+const REMOTE_ACCESS_ROUTE = getPluginConfigurationRoutePath({
+ pluginId: "connect",
+});
+
+/**
+ * Shown instead of the pairing command when connect is unpaired and the only
+ * server URL we know is loopback or unspecified (issue #1690). bb listens on
+ * loopback by default, so a command that targets this address dials the new
+ * machine itself instead of this server.
+ */
+function UnreachableServerNotice({ serverUrl }: { serverUrl: string }) {
+ return (
+
+
+ Another machine cannot use this address.
+
+
+ The pairing command would target{" "}
+ {serverUrl}, which points to the
+ machine that runs it, not to this bb. Set up remote access first, then
+ come back here to get a pairing command that works from anywhere.
+