diff --git a/apps/app/src/lib/dev-websocket-url.test.ts b/apps/app/src/lib/dev-websocket-url.test.ts
index b8c5ecb0de..e01783eae9 100644
--- a/apps/app/src/lib/dev-websocket-url.test.ts
+++ b/apps/app/src/lib/dev-websocket-url.test.ts
@@ -7,6 +7,7 @@ function installWindowLocation(url: string): void {
location: {
host: location.host,
hostname: location.hostname,
+ port: location.port,
protocol: location.protocol,
},
});
@@ -19,6 +20,7 @@ describe("buildDevWebSocketUrl", () => {
it("connects directly to the backend for HTTP source dev", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation("http://devbox.local:15802/threads/thr_1");
expect(buildDevWebSocketUrl({ path: "/ws" })).toBe(
@@ -28,6 +30,7 @@ describe("buildDevWebSocketUrl", () => {
it("uses the proxied app origin for HTTPS bb connect shares", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation(
"https://sawyer--15802.getbb.app/threads/thr_jew2ruik89",
);
@@ -37,13 +40,24 @@ describe("buildDevWebSocketUrl", () => {
);
});
+ it("uses the proxied app origin for HTTP local Cloud", () => {
+ vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
+ installWindowLocation("http://sawyer.localhost:35802/threads/thr_1");
+
+ expect(buildDevWebSocketUrl({ path: "/ws" })).toBe(
+ "ws://sawyer.localhost:35802/ws",
+ );
+ });
+
it("preserves terminal websocket paths on the proxied app origin", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation("https://dev.example.test:15802/threads/thr_1");
- expect(
- buildDevWebSocketUrl({ path: "/ws/terminals/term_1" }),
- ).toBe("wss://dev.example.test:15802/ws/terminals/term_1");
+ expect(buildDevWebSocketUrl({ path: "/ws/terminals/term_1" })).toBe(
+ "wss://dev.example.test:15802/ws/terminals/term_1",
+ );
});
it("returns undefined outside the dev build", () => {
diff --git a/apps/app/src/lib/dev-websocket-url.ts b/apps/app/src/lib/dev-websocket-url.ts
index 9101e33b9c..5ae6bae372 100644
--- a/apps/app/src/lib/dev-websocket-url.ts
+++ b/apps/app/src/lib/dev-websocket-url.ts
@@ -2,25 +2,35 @@ interface BuildDevWebSocketUrlArgs {
path: string;
}
-function resolveBrowserHostDevWebSocketBaseUrl(port: number): string {
+function resolveBrowserHostDevWebSocketBaseUrl(
+ serverPort: number,
+ appPort: number,
+): string {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
// HTTPS dev origins are typically reverse proxies or bb connect shares.
// Their public origin does not expose the backend's local TCP port, so keep
// the socket on the app origin and let Vite proxy /ws to the server.
- if (window.location.protocol === "https:") {
+ if (
+ window.location.protocol === "https:" ||
+ window.location.port !== String(appPort)
+ ) {
return `${protocol}//${window.location.host}/ws`;
}
// Direct sockets remain preferable for ordinary localhost/LAN source dev:
// they survive backend restarts more reliably than Vite's WS proxy.
- return `${protocol}//${window.location.hostname}:${port}/ws`;
+ return `${protocol}//${window.location.hostname}:${serverPort}/ws`;
}
function resolveDevWebSocketBaseUrl(): string | undefined {
- if (typeof __BB_DEV_WS_BROWSER_HOST_PORT__ === "number") {
+ if (
+ typeof __BB_DEV_WS_BROWSER_HOST_PORT__ === "number" &&
+ typeof __BB_DEV_APP_BROWSER_HOST_PORT__ === "number"
+ ) {
return resolveBrowserHostDevWebSocketBaseUrl(
__BB_DEV_WS_BROWSER_HOST_PORT__,
+ __BB_DEV_APP_BROWSER_HOST_PORT__,
);
}
diff --git a/apps/app/src/vite-env.d.ts b/apps/app/src/vite-env.d.ts
index a0ffd0ac7e..f7153037bc 100644
--- a/apps/app/src/vite-env.d.ts
+++ b/apps/app/src/vite-env.d.ts
@@ -2,3 +2,4 @@
/** Injected by vite.dev.config.ts to bypass Vite's WebSocket proxy. */
declare const __BB_DEV_WS_BROWSER_HOST_PORT__: number | undefined;
+declare const __BB_DEV_APP_BROWSER_HOST_PORT__: number | undefined;
diff --git a/apps/app/vite.dev.config.ts b/apps/app/vite.dev.config.ts
index 1d89e9766e..c616dc5f9a 100644
--- a/apps/app/vite.dev.config.ts
+++ b/apps/app/vite.dev.config.ts
@@ -13,6 +13,7 @@ export default defineConfig({
// Connect directly to the server in dev because Vite's WS proxy does not
// handle upstream server restarts reliably.
__BB_DEV_WS_BROWSER_HOST_PORT__: devWebSocketBrowserHostPortDefine,
+ __BB_DEV_APP_BROWSER_HOST_PORT__: JSON.stringify(viteDevConfig.appPort),
},
server: {
// Allow Tailscale MagicDNS names when Vite is behind Tailscale Serve.
diff --git a/apps/connect/src/cloud-dev.test.ts b/apps/connect/src/cloud-dev.test.ts
new file mode 100644
index 0000000000..f478cee086
--- /dev/null
+++ b/apps/connect/src/cloud-dev.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+import {
+ CLOUD_DEV_HOST_HEADER,
+ publicConnectOrigin,
+ resolveConnectRequestHost,
+ resolveConnectRequestUrl,
+ resolveConnectRuntime,
+} from "./cloud-dev.js";
+
+describe("local Cloud request routing", () => {
+ it("accepts the launcher host and selects HTTP cookies only in local Cloud", () => {
+ const runtime = resolveConnectRuntime({
+ ACCOUNT_APP_URL: "http://bb.localhost:8787",
+ BASE_DOMAIN: "bb.localhost",
+ CLOUD_DEV: "true",
+ });
+ const headers = new Headers({
+ host: "localhost",
+ [CLOUD_DEV_HOST_HEADER]: "sawyer--3000",
+ });
+ expect(resolveConnectRequestHost(headers, runtime)).toBe(
+ "sawyer--3000.bb.localhost",
+ );
+ expect(runtime.sessionCookieName).toBe("better-auth.session_token");
+ expect(runtime.desktopSessionCookieName).toBe("bb-connect.desktop_session");
+ expect(publicConnectOrigin("sawyer--3000", runtime)).toBe(
+ "http://sawyer--3000.bb.localhost:8787",
+ );
+ expect(
+ resolveConnectRequestUrl(
+ "http://127.0.0.1:50743/threads/thr_1?view=full",
+ headers,
+ runtime,
+ ).toString(),
+ ).toBe("http://sawyer--3000.bb.localhost:8787/threads/thr_1?view=full");
+ });
+
+ it("ignores the launcher header in production", () => {
+ const runtime = resolveConnectRuntime({ BASE_DOMAIN: "getbb.app" });
+ const headers = new Headers({
+ host: "sawyer.getbb.app",
+ [CLOUD_DEV_HOST_HEADER]: "attacker",
+ });
+ expect(resolveConnectRequestHost(headers, runtime)).toBe(
+ "sawyer.getbb.app",
+ );
+ expect(runtime.sessionCookieName).toBe(
+ "__Secure-better-auth.session_token",
+ );
+ });
+
+ it("rejects deployed credential auth", () => {
+ expect(() =>
+ resolveConnectRuntime({
+ ACCOUNT_APP_URL: "https://getbb.app",
+ BASE_DOMAIN: "getbb.app",
+ CLOUD_DEV: "true",
+ }),
+ ).toThrow("only allowed for local Cloud development");
+ });
+});
diff --git a/apps/connect/src/cloud-dev.ts b/apps/connect/src/cloud-dev.ts
new file mode 100644
index 0000000000..324407cdc4
--- /dev/null
+++ b/apps/connect/src/cloud-dev.ts
@@ -0,0 +1,112 @@
+export const CLOUD_DEV_HOST_HEADER = "x-bb-cloud-dev-host";
+export const SECURE_SESSION_COOKIE = "__Secure-better-auth.session_token";
+export const LOCAL_SESSION_COOKIE = "better-auth.session_token";
+export const SECURE_DESKTOP_SESSION_COOKIE =
+ "__Secure-bb-connect.desktop_session";
+export const LOCAL_DESKTOP_SESSION_COOKIE = "bb-connect.desktop_session";
+
+export interface ConnectRuntime {
+ accountAppUrl: string;
+ baseDomain: string;
+ localCloud: boolean;
+ sessionCookieName: string;
+ desktopSessionCookieName: string;
+}
+
+function resolveCloudDevLabel(
+ headers: Headers,
+ runtime: ConnectRuntime,
+): string | null {
+ if (!runtime.localCloud) return null;
+ const label = headers.get(CLOUD_DEV_HOST_HEADER)?.trim().toLowerCase();
+ return label && !label.includes(".") && /^[a-z0-9-]+$/u.test(label)
+ ? label
+ : null;
+}
+
+/** Resolve the small, fail-closed set of overrides used by local Cloud. */
+export function resolveConnectRuntime(env: {
+ ACCOUNT_APP_URL?: string;
+ BASE_DOMAIN: string;
+ CLOUD_DEV?: string;
+}): ConnectRuntime {
+ const accountAppUrl = new URL(
+ env.ACCOUNT_APP_URL?.trim() || `https://${env.BASE_DOMAIN}`,
+ );
+ if (
+ (accountAppUrl.protocol !== "http:" &&
+ accountAppUrl.protocol !== "https:") ||
+ accountAppUrl.username !== "" ||
+ accountAppUrl.password !== "" ||
+ accountAppUrl.pathname !== "/" ||
+ accountAppUrl.search !== "" ||
+ accountAppUrl.hash !== ""
+ ) {
+ throw new Error("ACCOUNT_APP_URL must be an HTTP(S) origin");
+ }
+
+ const cloudDevValue = env.CLOUD_DEV?.trim();
+ if (cloudDevValue && cloudDevValue !== "true") {
+ throw new Error("CLOUD_DEV must be true when set");
+ }
+ const localCloud = cloudDevValue === "true";
+ if (localCloud) {
+ const isLocalAccount =
+ accountAppUrl.protocol === "http:" &&
+ accountAppUrl.hostname === env.BASE_DOMAIN &&
+ env.BASE_DOMAIN.endsWith(".localhost");
+ if (!isLocalAccount) {
+ throw new Error("CLOUD_DEV is only allowed for local Cloud development");
+ }
+ }
+
+ return {
+ accountAppUrl: accountAppUrl.origin,
+ baseDomain: env.BASE_DOMAIN,
+ localCloud,
+ sessionCookieName: localCloud
+ ? LOCAL_SESSION_COOKIE
+ : SECURE_SESSION_COOKIE,
+ desktopSessionCookieName: localCloud
+ ? LOCAL_DESKTOP_SESSION_COOKIE
+ : SECURE_DESKTOP_SESSION_COOKIE,
+ };
+}
+
+/** Wrangler replaces wildcard hosts locally; the launcher preserves the label. */
+export function resolveConnectRequestHost(
+ headers: Headers,
+ runtime: ConnectRuntime,
+): string {
+ const ordinaryHost = headers.get("host") ?? "";
+ const label = resolveCloudDevLabel(headers, runtime);
+ return label === null ? ordinaryHost : `${label}.${runtime.baseDomain}`;
+}
+
+export function publicConnectOrigin(
+ label: string,
+ runtime: Pick,
+): string {
+ const url = new URL(runtime.accountAppUrl);
+ url.hostname = `${label}.${runtime.baseDomain}`;
+ return url.origin;
+}
+
+export function resolveConnectRequestUrl(
+ requestUrl: string,
+ headers: Headers,
+ runtime: ConnectRuntime,
+): URL {
+ const parsed = new URL(requestUrl);
+ const label = resolveCloudDevLabel(headers, runtime);
+ if (label === null) return parsed;
+ const publicUrl = new URL(publicConnectOrigin(label, runtime));
+ publicUrl.pathname = parsed.pathname;
+ publicUrl.search = parsed.search;
+ publicUrl.hash = parsed.hash;
+ return publicUrl;
+}
+
+export function stripCloudDevHeader(headers: Headers): void {
+ headers.delete(CLOUD_DEV_HOST_HEADER);
+}
diff --git a/apps/connect/src/machine-label.ts b/apps/connect/src/machine-label.ts
index 134c797a0a..da3c1a9bbb 100644
--- a/apps/connect/src/machine-label.ts
+++ b/apps/connect/src/machine-label.ts
@@ -8,10 +8,9 @@ import {
type ConnectDb,
} from "@bb/connect-db";
import { verifyMachineCredentialDetails } from "./session.js";
+import { MACHINE_CREDENTIAL_HEADER } from "./protocol-headers.js";
import type { Env } from "./tunnel-do.js";
-const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine";
-
function fallbackLabel(machineId: string): string {
const idPrefix = machineId
.replace(/[^a-z0-9]/giu, "")
diff --git a/apps/connect/src/protocol-headers.ts b/apps/connect/src/protocol-headers.ts
new file mode 100644
index 0000000000..83fb7559b8
--- /dev/null
+++ b/apps/connect/src/protocol-headers.ts
@@ -0,0 +1,4 @@
+export const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target";
+export const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine";
+export const GATE_AUTH_HEADER = "x-bb-gate-auth";
+export const GATE_MACHINE_ID_HEADER = "x-bb-gate-machine-id";
diff --git a/apps/connect/src/servers.test.ts b/apps/connect/src/servers.test.ts
index 8112046dcb..df8502c130 100644
--- a/apps/connect/src/servers.test.ts
+++ b/apps/connect/src/servers.test.ts
@@ -28,6 +28,7 @@ import {
assignMachineLabelForCredential,
sanitizeMachineLabelBase,
} from "./machine-label.js";
+import { SECURE_SESSION_COOKIE } from "./cloud-dev.js";
import { verifyMachineCredential } from "./session.js";
// Real in-memory SQLite (never mock the DB). Same harness as session.test.ts.
@@ -254,7 +255,12 @@ describe("verifyServerCredential / resolveAccountUserId", () => {
const req = new Request("https://sawyer.getbb.app/api/connect/servers", {
headers: { "x-bb-connect-machine": machinePlain },
});
- const userId = await resolveAccountUserId(req, "secret", db);
+ const userId = await resolveAccountUserId(
+ req,
+ "secret",
+ db,
+ SECURE_SESSION_COOKIE,
+ );
expect(userId).toBe("acct-a");
const listed = await listAccountServers(db, userId!, now.getTime());
expect(listed.map((s) => s.handle)).toEqual(["sawyer"]);
@@ -262,7 +268,9 @@ describe("verifyServerCredential / resolveAccountUserId", () => {
it("returns null (unauthorized) when no credential or session is presented", async () => {
const req = new Request("https://sawyer.getbb.app/api/connect/servers");
- expect(await resolveAccountUserId(req, "secret", db)).toBeNull();
+ expect(
+ await resolveAccountUserId(req, "secret", db, SECURE_SESSION_COOKIE),
+ ).toBeNull();
});
it("accepts a valid owner session cookie", async () => {
@@ -303,7 +311,24 @@ describe("verifyServerCredential / resolveAccountUserId", () => {
cookie: `__Secure-better-auth.session_token=${cookieValue}`,
},
});
- expect(await resolveAccountUserId(req, secret, db)).toBe("acct-a");
+ expect(
+ await resolveAccountUserId(req, secret, db, SECURE_SESSION_COOKIE),
+ ).toBe("acct-a");
+
+ const localRequest = new Request(
+ "http://sawyer.bb.localhost:8787/api/connect/servers",
+ {
+ headers: { cookie: `better-auth.session_token=${cookieValue}` },
+ },
+ );
+ expect(
+ await resolveAccountUserId(
+ localRequest,
+ secret,
+ db,
+ "better-auth.session_token",
+ ),
+ ).toBe("acct-a");
});
});
diff --git a/apps/connect/src/servers.ts b/apps/connect/src/servers.ts
index 3294bac531..054e1d470a 100644
--- a/apps/connect/src/servers.ts
+++ b/apps/connect/src/servers.ts
@@ -11,10 +11,10 @@ import {
verifyMachineCredential,
verifySessionCookie,
} from "./session.js";
+import { resolveConnectRuntime } from "./cloud-dev.js";
+import { MACHINE_CREDENTIAL_HEADER } from "./protocol-headers.js";
import type { Env } from "./tunnel-do.js";
-const SESSION_COOKIE = "__Secure-better-auth.session_token";
-export const DESKTOP_SESSION_COOKIE = "__Secure-bb-connect.desktop_session";
export const DESKTOP_SESSION_TTL_MS = 60 * 60 * 1000;
function bytesToBase64Url(bytes: Uint8Array): string {
@@ -167,8 +167,9 @@ export async function resolveAccountUserId(
request: Request,
secret: string,
db: ConnectDb,
+ sessionCookieName: string,
): Promise {
- const presented = request.headers.get("x-bb-connect-machine") ?? "";
+ const presented = request.headers.get(MACHINE_CREDENTIAL_HEADER) ?? "";
if (presented) {
const machineUserId = await verifyMachineCredential(presented, db);
if (machineUserId) return machineUserId;
@@ -179,7 +180,7 @@ export async function resolveAccountUserId(
if (serverUserId) return serverUserId;
}
- const cookie = parseCookie(request.headers.get("cookie"), SESSION_COOKIE);
+ const cookie = parseCookie(request.headers.get("cookie"), sessionCookieName);
if (!cookie) return null;
return verifySessionCookie(cookie, secret, db);
}
@@ -249,10 +250,12 @@ export async function handleListAccountServers(
}
const db = drizzle(env.DB, { schema });
+ const runtime = resolveConnectRuntime(env);
const userId = await resolveAccountUserId(
request,
env.BETTER_AUTH_SECRET,
db,
+ runtime.sessionCookieName,
);
if (!userId) {
return new Response(JSON.stringify({ error: "unauthorized" }), {
@@ -283,10 +286,12 @@ export async function handleCreateDesktopSession(
});
}
const db = drizzle(env.DB, { schema });
+ const runtime = resolveConnectRuntime(env);
const userId = await resolveAccountUserId(
request,
env.BETTER_AUTH_SECRET,
db,
+ runtime.sessionCookieName,
);
if (!userId) {
return new Response(JSON.stringify({ error: "unauthorized" }), {
@@ -305,7 +310,7 @@ export async function handleCreateDesktopSession(
cookie: {
domain: `.${env.BASE_DOMAIN}`,
expiresAt,
- name: DESKTOP_SESSION_COOKIE,
+ name: runtime.desktopSessionCookieName,
value,
},
}),
diff --git a/apps/connect/src/tunnel-do.ts b/apps/connect/src/tunnel-do.ts
index c65df2953f..7eca8777af 100644
--- a/apps/connect/src/tunnel-do.ts
+++ b/apps/connect/src/tunnel-do.ts
@@ -11,12 +11,15 @@ import {
type HeaderPair,
} from "@bb/tunnel-contract";
import { relayedResponse } from "./response-encoding.js";
+import { TUNNEL_TARGET_HEADER } from "./protocol-headers.js";
export interface Env {
TUNNEL_DO: DurableObjectNamespace;
DB: D1Database;
BASE_DOMAIN: string;
BETTER_AUTH_SECRET: string;
+ ACCOUNT_APP_URL?: string;
+ CLOUD_DEV?: string;
}
const TUNNEL_TAG = "tunnel";
@@ -26,9 +29,6 @@ const RESP_HEAD_TIMEOUT_MS = 30_000;
// run JS), kept under the 90s offline window.
const PRESENCE_INTERVAL_MS = 50_000;
-/** Gate → DO header carrying a share target; never forwarded to the origin. */
-const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target";
-
// Standard WebSocket readyState numbering (workerd's READY_STATE_OPEN; the
// constant itself is Cloudflare-only, so tests in Node use the number).
const WS_READY_STATE_OPEN = 1;
diff --git a/apps/connect/src/worker.test.ts b/apps/connect/src/worker.test.ts
index c69c7aa246..b20cb0951c 100644
--- a/apps/connect/src/worker.test.ts
+++ b/apps/connect/src/worker.test.ts
@@ -5,13 +5,15 @@ import { machine } from "@bb/connect-db";
import { cacheKey } from "./cache";
import { parseClientProtocolVersion } from "./tunnel-do";
import {
- GATE_AUTH_HEADER,
- GATE_MACHINE_ID_HEADER,
- TUNNEL_TARGET_HEADER,
cacheNamespace,
dashboardSignInUrl,
requestForTunnelDo,
} from "./worker";
+import {
+ GATE_AUTH_HEADER,
+ GATE_MACHINE_ID_HEADER,
+ TUNNEL_TARGET_HEADER,
+} from "./protocol-headers";
// ── pure helpers ────────────────────────────────────────────────────────────
@@ -112,7 +114,6 @@ vi.mock("./session.js", () => ({
}));
vi.mock("./servers.js", () => ({
- DESKTOP_SESSION_COOKIE: "__Secure-bb-connect.desktop_session",
handleCreateDesktopSession: vi.fn(),
handleListAccountServers: vi.fn(),
verifyDesktopSessionCookie: vi.fn(),
@@ -152,11 +153,11 @@ import {
verifySessionCookie,
} from "./session.js";
import {
- DESKTOP_SESSION_COOKIE,
handleCreateDesktopSession,
handleListAccountServers,
verifyDesktopSessionCookie,
} from "./servers.js";
+import { SECURE_DESKTOP_SESSION_COOKIE as DESKTOP_SESSION_COOKIE } from "./cloud-dev.js";
import { handleAssignMachineLabel } from "./machine-label.js";
import { serveWithCache } from "./cache.js";
import worker, { offlinePage, relativeTime, wantsHtml } from "./worker.js";
@@ -377,7 +378,10 @@ describe("gate tunnel authentication", () => {
"sawyer-air.getbb.app",
"/__tunnel?v=1&serverId=victim-server&machineId=spoofed-machine",
{
- headers: { authorization: `Bearer ${credential}` },
+ headers: {
+ authorization: `Bearer ${credential}`,
+ "x-bb-cloud-dev-host": "smuggled",
+ },
},
),
env as never,
@@ -395,6 +399,7 @@ describe("gate tunnel authentication", () => {
"machine-air",
);
expect(new URL(captured[0].url).searchParams.get("serverId")).toBeNull();
+ expect(captured[0].headers.get("x-bb-cloud-dev-host")).toBeNull();
});
it("dials immediately after a negative resolve and label assignment", async () => {
@@ -552,14 +557,20 @@ describe("machine gate auth", () => {
const { env, ctx, captured } = makeEnv(() => new Response("origin"));
const internal = await worker.fetch(
visitorRequest("sawyer.getbb.app", "/internal/session/open", {
- headers: { "x-bb-connect-machine": "bbcm_owner" },
+ headers: {
+ "x-bb-connect-machine": "bbcm_owner",
+ "x-bb-cloud-dev-host": "smuggled",
+ },
}),
env as never,
ctx,
);
const api = await worker.fetch(
visitorRequest("sawyer.getbb.app", "/api/v1/threads", {
- headers: { "x-bb-connect-machine": "bbcm_owner" },
+ headers: {
+ "x-bb-connect-machine": "bbcm_owner",
+ "x-bb-cloud-dev-host": "smuggled",
+ },
}),
env as never,
ctx,
@@ -572,6 +583,11 @@ describe("machine gate auth", () => {
(request) => request.headers.get("x-bb-connect-machine") === null,
),
).toBe(true);
+ expect(
+ captured.every(
+ (request) => request.headers.get("x-bb-cloud-dev-host") === null,
+ ),
+ ).toBe(true);
expect(
captured.every(
(request) => request.headers.get(GATE_AUTH_HEADER) === "machine",
@@ -630,13 +646,16 @@ describe("machine gate auth", () => {
async (path) => {
const { env, ctx, captured } = makeEnv(() => new Response("artifact"));
const response = await worker.fetch(
- visitorRequest("sawyer.getbb.app", path),
+ visitorRequest("sawyer.getbb.app", path, {
+ headers: { "x-bb-cloud-dev-host": "smuggled" },
+ }),
env as never,
ctx,
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("artifact");
expect(captured).toHaveLength(1);
+ expect(captured[0].headers.get("x-bb-cloud-dev-host")).toBeNull();
expect(mockVerifyMachine).not.toHaveBeenCalled();
},
);
@@ -693,6 +712,30 @@ describe("gate worker share hosts", () => {
expect(mockVerifySession).not.toHaveBeenCalled();
});
+ it("renders local machine links with HTTP and the shared gateway port", async () => {
+ mockResolveLabel.mockResolvedValue(resolvedMachine());
+ const { env, ctx } = makeEnv(() => new Response("origin"));
+ Object.assign(env, {
+ ACCOUNT_APP_URL: "http://bb.localhost:42745",
+ BASE_DOMAIN: "bb.localhost",
+ CLOUD_DEV: "true",
+ });
+ const response = await worker.fetch(
+ new Request("http://127.0.0.1:50743/", {
+ headers: {
+ host: "127.0.0.1:50743",
+ "x-bb-cloud-dev-host": "sawyer-air",
+ },
+ }),
+ env as never,
+ ctx,
+ );
+
+ const html = await response.text();
+ expect(html).toContain("sawyer-air--<port>.bb.localhost:42745");
+ expect(html).toContain('href="http://sawyer.bb.localhost:42745"');
+ });
+
it("applies the same owner-session check to machine share hosts", async () => {
mockResolveLabel.mockResolvedValue(resolvedMachine());
const ownerEnv = makeEnv(() => new Response("machine-origin"));
@@ -777,13 +820,17 @@ describe("gate worker share hosts", () => {
const { env, ctx, captured } = makeEnv(() => new Response("ok"));
await worker.fetch(
visitorRequest("sawyer.getbb.app", "/", {
- headers: { [TUNNEL_TARGET_HEADER]: "9999" },
+ headers: {
+ [TUNNEL_TARGET_HEADER]: "9999",
+ "x-bb-cloud-dev-host": "smuggled",
+ },
}),
env as never,
ctx,
);
expect(captured).toHaveLength(1);
expect(captured[0].headers.get(TUNNEL_TARGET_HEADER)).toBeNull();
+ expect(captured[0].headers.get("x-bb-cloud-dev-host")).toBeNull();
expect(mockServeWithCache).toHaveBeenCalledWith(
expect.any(Request),
"sawyer",
@@ -848,6 +895,31 @@ describe("gate worker share hosts", () => {
expect(captured).toHaveLength(0);
});
+ it("preserves the public local URL in the sign-in returnTo", async () => {
+ mockParseCookie.mockReturnValue(null);
+ const { env, ctx } = makeEnv(() => new Response("ok"));
+ Object.assign(env, {
+ ACCOUNT_APP_URL: "http://bb.localhost:42745",
+ BASE_DOMAIN: "bb.localhost",
+ CLOUD_DEV: "true",
+ });
+ const response = await worker.fetch(
+ new Request("http://127.0.0.1:50743/threads/thr_1?view=full", {
+ headers: {
+ host: "127.0.0.1:50743",
+ "x-bb-cloud-dev-host": "sawyer",
+ },
+ }),
+ env as never,
+ ctx,
+ );
+
+ expect(response.status).toBe(401);
+ expect(await response.text()).toContain(
+ "returnTo=http%3A%2F%2Fsawyer.bb.localhost%3A42745%2Fthreads%2Fthr_1%3Fview%3Dfull",
+ );
+ });
+
it("returns 403 when share host session is a different user", async () => {
mockVerifySession.mockResolvedValue(OTHER);
const { env, ctx, captured } = makeEnv(() => new Response("ok"));
diff --git a/apps/connect/src/worker.ts b/apps/connect/src/worker.ts
index 5776891e5a..0650209f4d 100644
--- a/apps/connect/src/worker.ts
+++ b/apps/connect/src/worker.ts
@@ -9,7 +9,6 @@ import {
verifySessionCookie,
} from "./session.js";
import {
- DESKTOP_SESSION_COOKIE,
handleCreateDesktopSession,
handleListAccountServers,
verifyDesktopSessionCookie,
@@ -17,17 +16,22 @@ import {
import { serveWithCache } from "./cache.js";
import { BB_ICON_DATA_URI } from "./bb-icon.js";
import { handleAssignMachineLabel } from "./machine-label.js";
+import {
+ publicConnectOrigin,
+ resolveConnectRequestHost,
+ resolveConnectRequestUrl,
+ resolveConnectRuntime,
+ stripCloudDevHeader,
+} from "./cloud-dev.js";
+import {
+ GATE_AUTH_HEADER,
+ GATE_MACHINE_ID_HEADER,
+ MACHINE_CREDENTIAL_HEADER,
+ TUNNEL_TARGET_HEADER,
+} from "./protocol-headers.js";
export { TunnelDO };
-const SESSION_COOKIE = "__Secure-better-auth.session_token";
-
-/** Internal header: gate → TunnelDO, share target (port string). Never trust visitors. */
-export const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target";
-export const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine";
-export const GATE_AUTH_HEADER = "x-bb-gate-auth";
-export const GATE_MACHINE_ID_HEADER = "x-bb-gate-machine-id";
-
async function sha256Hex(value: string): Promise {
const digest = await crypto.subtle.digest(
"SHA-256",
@@ -198,13 +202,15 @@ export function offlinePage(
export function machinePage(
label: string,
accountHandle: string,
- baseDomain: string,
+ runtime: ReturnType,
): Response {
- const appHost = `${accountHandle}.${baseDomain}`;
+ const appOrigin = publicConnectOrigin(accountHandle, runtime);
+ const appHost = new URL(appOrigin).host;
+ const baseHost = new URL(runtime.accountAppUrl).host;
return gatePage(
`${escapeHtml(label)} is a machine
- This machine is on ${escapeHtml(accountHandle)}'s account. Its shares appear at ${escapeHtml(label)}--<port>.${escapeHtml(baseDomain)}.
- Open the bb app at ${escapeHtml(appHost)}`,
+ This machine is on ${escapeHtml(accountHandle)}'s account. Its shares appear at ${escapeHtml(label)}--<port>.${escapeHtml(baseHost)}.
+ Open the bb app at ${escapeHtml(appHost)}`,
200,
);
}
@@ -223,6 +229,7 @@ export function requestForTunnelDo(
headers.delete(MACHINE_CREDENTIAL_HEADER);
headers.delete(GATE_AUTH_HEADER);
headers.delete(GATE_MACHINE_ID_HEADER);
+ stripCloudDevHeader(headers);
if (target !== null) {
headers.set(TUNNEL_TARGET_HEADER, target);
}
@@ -265,7 +272,8 @@ export default {
env: Env,
ctx: ExecutionContext,
): Promise {
- const url = new URL(request.url);
+ const runtime = resolveConnectRuntime(env);
+ const url = resolveConnectRequestUrl(request.url, request.headers, runtime);
// Account-scoped APIs are handled on the gate before host/label routing so
// they never proxy through a tunnel to a local bb origin. Auth is
// machine/server credential or owner session — see servers.ts.
@@ -279,7 +287,7 @@ export default {
return handleAssignMachineLabel(request, env);
}
- const host = request.headers.get("host") ?? url.host;
+ const host = resolveConnectRequestHost(request.headers, runtime);
const parsed = parseVisitorHost(host, env.BASE_DOMAIN);
if (!parsed) return text("bb connect: unknown host\n", 404);
// The base label is now ANY server's subdomain (the account handle names the
@@ -291,7 +299,7 @@ export default {
// rather than answering with a confusing "no server" page.
if (RESERVED_HANDLES.has(label)) {
return Response.redirect(
- `https://${env.BASE_DOMAIN}${url.pathname}${url.search}`,
+ `${runtime.accountAppUrl}${url.pathname}${url.search}`,
301,
);
}
@@ -342,7 +350,11 @@ export default {
} else {
forward.searchParams.set("machineId", owner.id);
}
- return stub.fetch(new Request(forward, request));
+ const headers = new Headers(request.headers);
+ stripCloudDevHeader(headers);
+ return stub.fetch(
+ new Request(new Request(forward, request), { headers }),
+ );
}
// Reserve the /__ namespace: never proxy internal paths from outside.
@@ -352,7 +364,7 @@ export default {
// Machine labels route only explicit `
+ {emailPasswordEnabled ? (
+ <>
+
+
+ {mode === "sign-in"
+ ? "New to this local Cloud?"
+ : "Already registered?"}{" "}
+
+
+
+
+ or
+
+
+ >
+ ) : null}
{state.servers.map((s: ServerSummary) => (
-
+
))}
@@ -1115,10 +1274,9 @@ function AccountDashboard({ state }: { state: ServerState }) {
{machine.subdomain !== null ? (
- {machine.subdomain}
-
- .{state.baseDomain}
-
+ {state.serverUrlTemplate
+ .replace("{label}", machine.subdomain)
+ .replace(/^https?:\/\//u, "")}
) : (
diff --git a/apps/web/src/server/api.test.ts b/apps/web/src/server/api.test.ts
index 0e0a8c6ca8..e4c5fbb833 100644
--- a/apps/web/src/server/api.test.ts
+++ b/apps/web/src/server/api.test.ts
@@ -26,6 +26,7 @@ import {
getAccountState,
redeemConnectCode,
redeemMachineCode,
+ resolveServerUrlTemplate,
revokeMachineForServerCredential,
revokeMachine,
} from "./api.js";
@@ -53,12 +54,32 @@ beforeEach(() => {
closeTunnel = vi.fn<(subdomain: string) => Promise>(async () => {});
deps = {
db,
- baseDomain: "getbb.app",
appUrl: "https://getbb.app",
+ serverUrlTemplate: "https://{label}.getbb.app",
closeTunnel,
};
});
+describe("resolveServerUrlTemplate", () => {
+ it("accepts the local HTTP port without changing production defaults", () => {
+ expect(resolveServerUrlTemplate(undefined, "getbb.app")).toBe(
+ "https://{label}.getbb.app",
+ );
+ expect(
+ resolveServerUrlTemplate(
+ "http://{label}.bb.localhost:8787",
+ "bb.localhost",
+ ),
+ ).toBe("http://{label}.bb.localhost:8787");
+ expect(() =>
+ resolveServerUrlTemplate("https://example.com/{label}", "example.com"),
+ ).toThrow("under BASE_DOMAIN");
+ expect(() =>
+ resolveServerUrlTemplate("https://{label}.attacker.example", "getbb.app"),
+ ).toThrow("under BASE_DOMAIN");
+ });
+});
+
afterEach(() => {
sqlite.close();
});
@@ -308,6 +329,25 @@ describe("redeemConnectCode (multi-server routing label)", () => {
expect(result.handle).toBe("sawyer");
expect(result.tunnelUrl).toBe("wss://sawyer.getbb.app/__tunnel");
});
+
+ it("returns a ws tunnel URL for local Cloud", async () => {
+ deps.serverUrlTemplate = "http://{label}.bb.localhost:42745";
+ seedUser("u1");
+ await claimHandle(deps, "u1", "sawyer");
+ const primary = db
+ .select()
+ .from(server)
+ .where(eq(server.subdomain, "sawyer"))
+ .get();
+ const minted = await createConnectCode(deps, "u1", {
+ serverId: primary!.id,
+ });
+ if ("error" in minted) throw new Error(minted.error);
+
+ const result = await redeemConnectCode(deps, minted.code);
+ if ("error" in result) throw new Error(result.error);
+ expect(result.tunnelUrl).toBe("ws://sawyer.bb.localhost:42745/__tunnel");
+ });
});
describe("disconnectServer (server-scoped)", () => {
diff --git a/apps/web/src/server/api.ts b/apps/web/src/server/api.ts
index abdcd3ea6d..fe554ead8b 100644
--- a/apps/web/src/server/api.ts
+++ b/apps/web/src/server/api.ts
@@ -26,16 +26,49 @@ import { generateConnectCode, generateToken, sha256Hex } from "./tokens.js";
*/
export interface Deps {
db: ConnectDb;
- baseDomain: string;
appUrl: string;
+ serverUrlTemplate: string;
closeTunnel?: (routingKey: string) => Promise;
}
+export function resolveServerUrlTemplate(
+ value: string | undefined,
+ baseDomain: string,
+): string {
+ const template = value?.trim() || `https://{label}.${baseDomain}`;
+ if (template.split("{label}").length !== 2) {
+ throw new Error("CONNECT_SERVER_URL_TEMPLATE must contain {label} once");
+ }
+ const probe = "bb-label-probe";
+ const url = new URL(template.replace("{label}", probe));
+ if (
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
+ url.username !== "" ||
+ url.password !== "" ||
+ url.pathname !== "/" ||
+ url.search !== "" ||
+ url.hash !== "" ||
+ url.hostname !== `${probe}.${baseDomain}`
+ ) {
+ throw new Error(
+ "CONNECT_SERVER_URL_TEMPLATE must be an HTTP(S) origin under BASE_DOMAIN",
+ );
+ }
+ return `${url.protocol}//{label}.${baseDomain}${url.port ? `:${url.port}` : ""}`;
+}
+
+function serverUrlForLabel(label: string, template: string): string {
+ return template.replace("{label}", label);
+}
+
export function depsFromEnv(env: Env): Deps {
return {
db: drizzle(env.DB),
- baseDomain: env.BASE_DOMAIN,
appUrl: env.APP_URL,
+ serverUrlTemplate: resolveServerUrlTemplate(
+ env.CONNECT_SERVER_URL_TEMPLATE,
+ env.BASE_DOMAIN,
+ ),
closeTunnel: async (routingKey) => {
const stub = env.TUNNEL_DO.get(env.TUNNEL_DO.idFromName(routingKey));
const response = await stub.fetch("https://tunnel/__control/close");
@@ -79,7 +112,7 @@ export interface AccountState {
/** Primary first, then oldest → newest. Empty until a handle is claimed. */
servers: ServerSummary[];
appUrl: string;
- baseDomain: string;
+ serverUrlTemplate: string;
/** GitHub login for the account footer link; null for pre-column rows. */
githubLogin: string | null;
/** Per-account server ceiling, surfaced in the footer as "N of MAX bbs". */
@@ -103,7 +136,7 @@ type ServerRow = typeof server.$inferSelect;
function toServerSummary(
srv: ServerRow,
handle: string,
- baseDomain: string,
+ serverUrlTemplate: string,
now: number,
): ServerSummary {
const lastSeenMs = srv.lastSeenAt?.getTime() ?? null;
@@ -121,7 +154,7 @@ function toServerSummary(
lastSeenAt: lastSeenMs,
version: srv.version,
createdAt: srv.createdAt.getTime(),
- serverUrl: `https://${srv.subdomain}.${baseDomain}`,
+ serverUrl: serverUrlForLabel(srv.subdomain, serverUrlTemplate),
};
}
@@ -165,7 +198,7 @@ export async function getAccountState(
deps: Deps,
userId: string,
): Promise {
- const { db, baseDomain } = deps;
+ const { db, serverUrlTemplate } = deps;
await retryPendingMachineRevocations(deps, userId);
const prof = await db
.select()
@@ -181,7 +214,7 @@ export async function getAccountState(
const now = Date.now();
const base = {
appUrl: deps.appUrl,
- baseDomain,
+ serverUrlTemplate,
githubLogin: userRow?.githubLogin ?? null,
maxServers: MAX_SERVERS_PER_ACCOUNT,
};
@@ -222,7 +255,7 @@ export async function getAccountState(
.all();
const servers = serverRows
- .map((srv) => toServerSummary(srv, prof.handle, baseDomain, now))
+ .map((srv) => toServerSummary(srv, prof.handle, serverUrlTemplate, now))
.sort((a, b) =>
a.isPrimary !== b.isPrimary
? a.isPrimary
@@ -385,7 +418,7 @@ export async function createServer(
userId: string,
rawLabel: string,
): Promise<{ ok: true; server: ServerSummary } | { error: CreateServerError }> {
- const { db, baseDomain } = deps;
+ const { db, serverUrlTemplate } = deps;
const prof = await db
.select()
.from(profile)
@@ -436,7 +469,12 @@ export async function createServer(
}
return {
ok: true,
- server: toServerSummary(created, prof.handle, baseDomain, Date.now()),
+ server: toServerSummary(
+ created,
+ prof.handle,
+ serverUrlTemplate,
+ Date.now(),
+ ),
};
}
@@ -457,10 +495,10 @@ export async function createConnectCode(
userId: string,
opts: { serverId?: string; reuse?: boolean } = {},
): Promise {
- const { db, baseDomain } = deps;
+ const { db, serverUrlTemplate } = deps;
const srv = await resolveServer(db, userId, opts.serverId);
if (!srv) return { error: "no-server" };
- const serverUrl = `https://${srv.subdomain}.${baseDomain}`;
+ const serverUrl = serverUrlForLabel(srv.subdomain, serverUrlTemplate);
const now = Date.now();
if (opts.reuse) {
@@ -517,7 +555,7 @@ export async function createMachineCode(
): Promise<
{ code: string; expiresInMs: number; serverUrl: string } | { error: string }
> {
- const { db, baseDomain } = deps;
+ const { db, serverUrlTemplate } = deps;
const prof = await db
.select()
.from(profile)
@@ -559,7 +597,7 @@ export async function createMachineCode(
return {
code,
expiresInMs: CONNECT_CODE_TTL_MS,
- serverUrl: `https://${srv.subdomain}.${baseDomain}`,
+ serverUrl: serverUrlForLabel(srv.subdomain, serverUrlTemplate),
};
}
@@ -717,7 +755,7 @@ function rowsChanged(result: unknown): number {
* Accepts `Deps` (D1 in the worker via `depsFromEnv`, better-sqlite3 in tests).
*/
export async function redeemConnectCode(
- deps: Pick,
+ deps: Pick,
code: string,
): Promise<
| {
@@ -728,7 +766,7 @@ export async function redeemConnectCode(
}
| { error: string; status: number }
> {
- const { db, baseDomain } = deps;
+ const { db, serverUrlTemplate } = deps;
const normalized = code.trim().toUpperCase();
if (!normalized) return { error: "missing-code", status: 400 };
@@ -765,19 +803,19 @@ export async function redeemConnectCode(
.from(server)
.where(eq(server.id, row.serverId))
.get();
- const prof = await db
- .select()
- .from(profile)
- .where(eq(profile.userId, row.userId))
- .get();
// Routing label of the redeemed server (not necessarily the account handle).
- const handle = srv?.subdomain ?? prof?.handle ?? null;
+ const handle = srv?.subdomain ?? null;
+ const serverUrl = handle
+ ? serverUrlForLabel(handle, serverUrlTemplate)
+ : null;
return {
credential,
serverId: row.serverId,
handle,
// Keyed by this server's subdomain (which may be non-primary), not the account handle.
- tunnelUrl: srv ? `wss://${srv.subdomain}.${baseDomain}/__tunnel` : null,
+ tunnelUrl: serverUrl
+ ? `${serverUrl.replace(/^http/u, "ws")}/__tunnel`
+ : null,
};
}
@@ -786,7 +824,7 @@ export async function redeemConnectCode(
* creates a machine row, and returns the durable machine credential once.
*/
export async function redeemMachineCode(
- deps: Pick,
+ deps: Pick,
code: string,
): Promise<
| {
@@ -797,7 +835,7 @@ export async function redeemMachineCode(
}
| { error: string; status: number }
> {
- const { db, baseDomain } = deps;
+ const { db, serverUrlTemplate } = deps;
const normalized = code.trim().toUpperCase();
if (!normalized) return { error: "missing-code", status: 400 };
@@ -866,6 +904,6 @@ export async function redeemMachineCode(
credential,
machineId,
handle: prof?.handle ?? null,
- serverUrl: label ? `https://${label}.${baseDomain}` : null,
+ serverUrl: label ? serverUrlForLabel(label, serverUrlTemplate) : null,
};
}
diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts
index 0b417223ff..2097915a52 100644
--- a/apps/web/src/server/auth.ts
+++ b/apps/web/src/server/auth.ts
@@ -3,21 +3,28 @@ import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { drizzle } from "drizzle-orm/d1";
import { account, session, user, verification } from "@bb/connect-db";
import type { Env } from "./env.js";
+import { resolveDevEmailPasswordEnabled } from "./local-auth.js";
export type Auth = ReturnType;
/**
- * better-auth bound to the staging D1 via drizzle. GitHub is the only provider.
+ * better-auth bound to the Cloud D1 via drizzle. Production uses GitHub;
+ * local Cloud additionally enables email/password credentials.
* Cookies are scoped to `.${BASE_DOMAIN}` so the tunnel gate on
* `.${BASE_DOMAIN}` can validate the same session.
*/
export function createAuth(env: Env) {
const db = drizzle(env.DB);
+ const appUrl = new URL(env.APP_URL);
+ const devEmailPasswordEnabled = resolveDevEmailPasswordEnabled(env);
+ const subdomainOrigin = `${appUrl.protocol}//*.${env.BASE_DOMAIN}${
+ appUrl.port ? `:${appUrl.port}` : ""
+ }`;
return betterAuth({
appName: "bb connect",
secret: env.BETTER_AUTH_SECRET,
baseURL: env.APP_URL,
- trustedOrigins: [env.APP_URL, `https://*.${env.BASE_DOMAIN}`],
+ trustedOrigins: [env.APP_URL, subdomainOrigin],
// `better-auth` and `@better-auth/drizzle-adapter` resolve to two copies of
// `@better-auth/core` under pnpm (different peer hashes — workers-types is in
// one peer set), so the adapter's type is nominally distinct though identical
@@ -26,7 +33,7 @@ export function createAuth(env: Env) {
provider: "sqlite",
schema: { user, session, account, verification },
}) as unknown as Parameters[0]["database"],
- emailAndPassword: { enabled: false },
+ emailAndPassword: { enabled: devEmailPasswordEnabled },
user: {
additionalFields: {
githubLogin: { type: "string", required: false, input: false },
diff --git a/apps/web/src/server/cloud-dev-vite.test.ts b/apps/web/src/server/cloud-dev-vite.test.ts
new file mode 100644
index 0000000000..2748d429cf
--- /dev/null
+++ b/apps/web/src/server/cloud-dev-vite.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { resolveCloudDevViteSettings } from "./cloud-dev-vite.js";
+
+const localEnv = {
+ BB_CLOUD_DEV_STATE_PATH: "/tmp/cloud-dev",
+ BB_CLOUD_DEV_APP_URL: "http://bb.localhost:42745",
+ BB_CLOUD_DEV_SERVER_URL_TEMPLATE: "http://{label}.bb.localhost:42745",
+ BETTER_AUTH_SECRET: "shared-local-secret",
+};
+
+describe("resolveCloudDevViteSettings", () => {
+ it("ignores local Cloud environment variables during builds", () => {
+ expect(resolveCloudDevViteSettings("build", localEnv)).toBeNull();
+ });
+
+ it("configures local auth during the launcher-owned dev server", () => {
+ expect(resolveCloudDevViteSettings("serve", localEnv)).toEqual({
+ persistStatePath: "/tmp/cloud-dev",
+ vars: {
+ APP_URL: "http://bb.localhost:42745",
+ BASE_DOMAIN: "bb.localhost",
+ BETTER_AUTH_SECRET: "shared-local-secret",
+ CONNECT_SERVER_URL_TEMPLATE: "http://{label}.bb.localhost:42745",
+ DEV_EMAIL_PASSWORD_AUTH: "true",
+ GITHUB_CLIENT_ID: "local-cloud-dev-unused",
+ GITHUB_CLIENT_SECRET: "local-cloud-dev-unused",
+ },
+ });
+ });
+});
diff --git a/apps/web/src/server/cloud-dev-vite.ts b/apps/web/src/server/cloud-dev-vite.ts
new file mode 100644
index 0000000000..e19d1c0786
--- /dev/null
+++ b/apps/web/src/server/cloud-dev-vite.ts
@@ -0,0 +1,32 @@
+export interface CloudDevViteSettings {
+ persistStatePath: string;
+ vars: Record;
+}
+
+export function resolveCloudDevViteSettings(
+ command: string,
+ env: Record,
+): CloudDevViteSettings | null {
+ if (command !== "serve") return null;
+
+ const persistStatePath = env.BB_CLOUD_DEV_STATE_PATH?.trim();
+ const appUrl = env.BB_CLOUD_DEV_APP_URL?.trim();
+ const serverUrlTemplate = env.BB_CLOUD_DEV_SERVER_URL_TEMPLATE?.trim();
+ const betterAuthSecret = env.BETTER_AUTH_SECRET?.trim();
+ if (!persistStatePath || !appUrl || !serverUrlTemplate || !betterAuthSecret) {
+ return null;
+ }
+
+ return {
+ persistStatePath,
+ vars: {
+ APP_URL: appUrl,
+ BASE_DOMAIN: new URL(appUrl).hostname,
+ BETTER_AUTH_SECRET: betterAuthSecret,
+ CONNECT_SERVER_URL_TEMPLATE: serverUrlTemplate,
+ DEV_EMAIL_PASSWORD_AUTH: "true",
+ GITHUB_CLIENT_ID: "local-cloud-dev-unused",
+ GITHUB_CLIENT_SECRET: "local-cloud-dev-unused",
+ },
+ };
+}
diff --git a/apps/web/src/server/env.ts b/apps/web/src/server/env.ts
index ed3d8d8cf4..83ad25310d 100644
--- a/apps/web/src/server/env.ts
+++ b/apps/web/src/server/env.ts
@@ -5,6 +5,8 @@ export interface Env {
TUNNEL_DO: DurableObjectNamespace;
BASE_DOMAIN: string;
APP_URL: string;
+ CONNECT_SERVER_URL_TEMPLATE?: string;
+ DEV_EMAIL_PASSWORD_AUTH?: string;
GITHUB_CLIENT_ID: string;
GITHUB_CLIENT_SECRET: string;
BETTER_AUTH_SECRET: string;
diff --git a/apps/web/src/server/fns.ts b/apps/web/src/server/fns.ts
index 4a50dec7d3..eecb41a3f0 100644
--- a/apps/web/src/server/fns.ts
+++ b/apps/web/src/server/fns.ts
@@ -14,22 +14,29 @@ import {
} from "./api.js";
import { getEnv } from "./env.js";
import { getSessionUserId } from "./current-user.server.js";
+import { resolveDevEmailPasswordEnabled } from "./local-auth.js";
// The ONLY server module the client route imports. Everything here is a
// createServerFn, so the client receives RPC stubs and none of the server-only
// imports (D1, better-auth, cloudflare:workers) land in the client bundle.
export type DashboardState =
- | { authed: false }
+ | { authed: false; emailPasswordEnabled: boolean }
| ({ authed: true } & AccountState);
export const getDashboard = createServerFn({ method: "GET" }).handler(
async (): Promise => {
+ const env = getEnv();
const userId = await getSessionUserId();
- if (!userId) return { authed: false };
+ if (!userId) {
+ return {
+ authed: false,
+ emailPasswordEnabled: resolveDevEmailPasswordEnabled(env),
+ };
+ }
return {
authed: true,
- ...(await getAccountState(depsFromEnv(getEnv()), userId)),
+ ...(await getAccountState(depsFromEnv(env), userId)),
};
},
);
diff --git a/apps/web/src/server/local-auth.test.ts b/apps/web/src/server/local-auth.test.ts
new file mode 100644
index 0000000000..d51142fbfa
--- /dev/null
+++ b/apps/web/src/server/local-auth.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { resolveDevEmailPasswordEnabled } from "./local-auth.js";
+
+describe("resolveDevEmailPasswordEnabled", () => {
+ it("enables credential auth only on the local Cloud origin", () => {
+ expect(
+ resolveDevEmailPasswordEnabled({
+ APP_URL: "http://bb.localhost:8787",
+ BASE_DOMAIN: "bb.localhost",
+ DEV_EMAIL_PASSWORD_AUTH: "true",
+ }),
+ ).toBe(true);
+
+ expect(
+ resolveDevEmailPasswordEnabled({
+ APP_URL: "https://getbb.app",
+ BASE_DOMAIN: "getbb.app",
+ }),
+ ).toBe(false);
+
+ expect(() =>
+ resolveDevEmailPasswordEnabled({
+ APP_URL: "https://getbb.app",
+ BASE_DOMAIN: "getbb.app",
+ DEV_EMAIL_PASSWORD_AUTH: "true",
+ }),
+ ).toThrow("only allowed for local Cloud development");
+ });
+
+ it("rejects ambiguous flag values", () => {
+ expect(() =>
+ resolveDevEmailPasswordEnabled({
+ APP_URL: "http://bb.localhost:8787",
+ BASE_DOMAIN: "bb.localhost",
+ DEV_EMAIL_PASSWORD_AUTH: "1",
+ }),
+ ).toThrow("must be true when set");
+ });
+});
diff --git a/apps/web/src/server/local-auth.ts b/apps/web/src/server/local-auth.ts
new file mode 100644
index 0000000000..33b597642a
--- /dev/null
+++ b/apps/web/src/server/local-auth.ts
@@ -0,0 +1,29 @@
+import type { Env } from "./env.js";
+
+/** Enable credential auth only for the launcher's local HTTP origin. */
+export function resolveDevEmailPasswordEnabled(
+ env: Pick,
+): boolean {
+ const value = env.DEV_EMAIL_PASSWORD_AUTH?.trim();
+ if (!value) return false;
+ if (value !== "true") {
+ throw new Error("DEV_EMAIL_PASSWORD_AUTH must be true when set");
+ }
+
+ const appUrl = new URL(env.APP_URL);
+ const isLocalOrigin =
+ appUrl.protocol === "http:" &&
+ appUrl.hostname === env.BASE_DOMAIN &&
+ env.BASE_DOMAIN.endsWith(".localhost") &&
+ appUrl.username === "" &&
+ appUrl.password === "" &&
+ appUrl.pathname === "/" &&
+ appUrl.search === "" &&
+ appUrl.hash === "";
+ if (!isLocalOrigin) {
+ throw new Error(
+ "DEV_EMAIL_PASSWORD_AUTH is only allowed for local Cloud development",
+ );
+ }
+ return true;
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 984700df00..7f7ee48585 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,23 +1,46 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
-import { cloudflare } from "@cloudflare/vite-plugin";
+import {
+ cloudflare,
+ type PluginConfig,
+ type WorkerConfig,
+} from "@cloudflare/vite-plugin";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
+import { resolveCloudDevViteSettings } from "./src/server/cloud-dev-vite.js";
-export default defineConfig({
- resolve: {
- alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
- },
- // Dev binds all interfaces so the server is reachable over the tailnet
- // (see the dev script's --host 0.0.0.0); allow Tailscale MagicDNS names.
- server: {
- allowedHosts: [".ts.net"],
- },
- plugins: [
- cloudflare({ viteEnvironment: { name: "ssr" } }),
- tailwindcss(),
- tanstackStart(),
- viteReact(),
- ],
+export default defineConfig(({ command }) => {
+ const cloudDev = resolveCloudDevViteSettings(command, process.env);
+ const cloudflareConfig: PluginConfig = {
+ viteEnvironment: { name: "ssr" },
+ ...(cloudDev
+ ? {
+ persistState: { path: cloudDev.persistStatePath },
+ config: (config: WorkerConfig) => ({
+ vars: {
+ ...config.vars,
+ ...cloudDev.vars,
+ },
+ }),
+ }
+ : {}),
+ };
+
+ return {
+ resolve: {
+ alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
+ },
+ // Dev binds all interfaces so the server is reachable over the tailnet
+ // (see the dev script's --host 0.0.0.0); allow Tailscale MagicDNS names.
+ server: {
+ allowedHosts: [".localhost", ".ts.net"],
+ },
+ plugins: [
+ cloudflare(cloudflareConfig),
+ tailwindcss(),
+ tanstackStart(),
+ viteReact(),
+ ],
+ };
});
diff --git a/docs/configuration.md b/docs/configuration.md
index f2e4486433..ba4d2314b2 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -59,6 +59,14 @@ For the packaged app, prefer `bb-app config`, `bb-app env`, and launcher flags
over shell variables. The environment remains the internal and deployment
substrate, and source-development commands still load `.env` files.
+For source development, `pnpm dev` automatically injects
+`BB_DEV_CONNECT_BASE_URL=http://bb.localhost:`. The
+Connect plugin accepts this loopback origin only when `NODE_ENV=development`
+and uses it only as the unpaired default. Explicit `bb connect --server ...`
+or `--base-url ...` targets take precedence, and packaged/production bb keeps
+the `https://getbb.app` default. This value is launcher-managed, not a
+`bb-app config` setting.
+
After `bb-app config` writes `~/.bb/config.json` or `bb-app env` writes
`~/.bb/env.json`, it asks the running local server to reload. If bb is not
running, the new values apply on the next start. If you edit either file by
diff --git a/docs/debugging-and-qa.md b/docs/debugging-and-qa.md
index 095bf3dc2a..056debcb37 100644
--- a/docs/debugging-and-qa.md
+++ b/docs/debugging-and-qa.md
@@ -30,3 +30,29 @@ Test agents with:
eval "$(scripts/bb-dev-app env)"
pnpm bb:dev thread spawn --project proj_personal --provider codex --permission-mode accept-edits --title "Smoke test" --prompt "Reply only with ok." --json
```
+
+## Local Cloud
+
+Run the Cloud dashboard and Connect worker against one local D1 database:
+
+```bash
+pnpm cloud:dev
+```
+
+The command applies migrations and prints the dashboard URL. Create a local
+email/password account, claim a handle, create a pairing code, and run the
+displayed `bb connect` command against a bb started with `pnpm dev`. The same
+worktree-specific local origin serves the dashboard at `bb.localhost` and
+routes `.bb.localhost` through the Connect worker. Email/password auth
+is enabled only for this loopback workflow; production remains GitHub-only.
+`pnpm dev` automatically sets `BB_DEV_CONNECT_BASE_URL` to that worktree's
+local Cloud origin. While the bb is unpaired, Settings → Plugins → Connect
+therefore opens the local dashboard and a pasted code redeems locally. An
+explicit `bb connect --server ...` or `--base-url ...` still wins, so the dev bb
+can still pair with getbb.app.
+Local machine enrollment follows the same origin: local `http:` server URLs
+produce `ws:` machine tunnels and `http:` share URLs, while non-local machine
+enrollment remains HTTPS-only.
+
+Ctrl-C stops the local services. Local D1 state is kept under
+`.wrangler/cloud-dev`.
diff --git a/package.json b/package.json
index 7b24c19717..9a017aaf98 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"cli:prepare": "pnpm exec turbo run build --filter=@bb/scripts --filter=@bb/cli --output-logs=none --log-prefix=none --summarize=false",
"ensure-native-modules": "node scripts/ensure-native-modules.mjs",
"dev": "node scripts/ensure-native-modules.mjs && cross-env NODE_ENV=development dotenv -c development -- node --conditions=source --import tsx packages/scripts/src/commands/run-dev.ts",
+ "cloud:dev": "node --conditions=source --import tsx scripts/bb-cloud-dev.mjs",
"dev:desktop": "scripts/bb-dev-app current --desktop",
"dev:status": "scripts/bb-dev-app status",
"dev:restart": "cross-env NODE_ENV=development node --conditions=source --import tsx packages/scripts/src/commands/request-dev-restart.ts both",
@@ -46,6 +47,7 @@
"cross-env": "^10.1.0",
"dotenv-cli": "^11.0.0",
"esbuild": "^0.28.0",
+ "http-proxy-3": "^1.23.2",
"prettier": "^3.8.3",
"rimraf": "^6.1.0",
"tsx": "^4.23.1",
diff --git a/packages/config/src/runtime.ts b/packages/config/src/runtime.ts
index cfbc43c6b1..6a0f971683 100644
--- a/packages/config/src/runtime.ts
+++ b/packages/config/src/runtime.ts
@@ -6,6 +6,8 @@ export type BbRuntimeMode = "dev" | "prod";
export interface DevPortSet {
appPort: number;
+ cloudPort: number;
+ cloudWorkerPort: number;
hostDaemonPort: number;
serverPort: number;
}
@@ -88,6 +90,8 @@ const DEV_PORT_BUCKETS = 8_000;
const DEV_APP_PORT_BASE = 11_000;
const DEV_SERVER_PORT_BASE = 19_000;
const DEV_HOST_DAEMON_PORT_BASE = 27_000;
+const DEV_CLOUD_PORT_BASE = 35_000;
+const DEV_CLOUD_WORKER_PORT_BASE = 43_000;
const DEV_PROCESS_STRIPPED_ENV_KEYS: readonly string[] = [
"BB_ENVIRONMENT_ID",
"BB_THREAD_ID",
@@ -134,10 +138,18 @@ function resolvePortOffset(repoRootPath: string): number {
return Number.parseInt(hash.slice(0, 8), 16) % DEV_PORT_BUCKETS;
}
+function reservePackagedAppPorts(port: number): number {
+ if (port === BB_PROD_SERVER_PORT) return 59_000;
+ if (port === BB_PROD_HOST_DAEMON_PORT) return 59_001;
+ return port;
+}
+
function resolvePorts(repoRootPath: string): DevPortSet {
const offset = resolvePortOffset(repoRootPath);
return {
appPort: DEV_APP_PORT_BASE + offset,
+ cloudPort: reservePackagedAppPorts(DEV_CLOUD_PORT_BASE + offset),
+ cloudWorkerPort: DEV_CLOUD_WORKER_PORT_BASE + offset,
hostDaemonPort: DEV_HOST_DAEMON_PORT_BASE + offset,
serverPort: DEV_SERVER_PORT_BASE + offset,
};
@@ -305,6 +317,7 @@ export function toDevProcessEnv(args: DevProcessEnvArgs): NodeJS.ProcessEnv {
...env,
BB_DATA_DIR: args.config.dataDir,
BB_DEV_APP_PORT: String(args.config.ports.appPort),
+ BB_DEV_CONNECT_BASE_URL: `http://bb.localhost:${args.config.ports.cloudPort}`,
BB_HOST_DAEMON_PORT: String(args.config.ports.hostDaemonPort),
...(inheritedSkillsRootPaths.length > 0
? { BB_INHERITED_SKILLS_ROOTS: inheritedSkillsRootPaths.join(delimiter) }
diff --git a/packages/connect-client/src/credential.ts b/packages/connect-client/src/credential.ts
index fdb9ddd94a..a155917749 100644
--- a/packages/connect-client/src/credential.ts
+++ b/packages/connect-client/src/credential.ts
@@ -13,6 +13,16 @@ export const connectCredentialSchema = z.object({
export type ConnectCredential = z.infer;
+export type ConnectPublicProtocol = "http:" | "https:";
+
+/** Local Cloud is HTTP-only; every non-local Connect gate is HTTPS-only. */
+export function connectPublicProtocol(
+ baseDomain: string,
+): ConnectPublicProtocol {
+ const hostname = new URL(`https://${baseDomain}`).hostname;
+ return hostname.endsWith(".localhost") ? "http:" : "https:";
+}
+
/**
* Derive the connect cloud apex (`https://getbb.app`) from a server URL
* (`https://.getbb.app`) by dropping the handle label.
diff --git a/packages/connect-client/src/index.ts b/packages/connect-client/src/index.ts
index 3248e4d174..e093662c0e 100644
--- a/packages/connect-client/src/index.ts
+++ b/packages/connect-client/src/index.ts
@@ -1,8 +1,10 @@
export {
connectCredentialSchema,
+ connectPublicProtocol,
deriveConnectBaseUrl,
serverUrlForHandle,
type ConnectCredential,
+ type ConnectPublicProtocol,
} from "./credential.js";
export {
ConnectListError,
diff --git a/packages/connect-client/test/connect-client.test.ts b/packages/connect-client/test/connect-client.test.ts
index e3e51866e1..7ca278dd57 100644
--- a/packages/connect-client/test/connect-client.test.ts
+++ b/packages/connect-client/test/connect-client.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import {
ConnectListError,
ConnectMachineRedeemError,
+ connectPublicProtocol,
deriveConnectBaseUrl,
listAccountServers,
redeemMachineCredential,
@@ -26,6 +27,8 @@ describe("connect URL helpers", () => {
expect(deriveConnectBaseUrl("https://laptop.bb.example:8443")).toBe(
"https://bb.example:8443",
);
+ expect(connectPublicProtocol("bb.localhost:42745")).toBe("http:");
+ expect(connectPublicProtocol("getbb.app")).toBe("https:");
});
});
diff --git a/packages/scripts/test/cloud-dev-proxy.test.mjs b/packages/scripts/test/cloud-dev-proxy.test.mjs
new file mode 100644
index 0000000000..adb685b5ce
--- /dev/null
+++ b/packages/scripts/test/cloud-dev-proxy.test.mjs
@@ -0,0 +1,32 @@
+import { describe, expect, it, vi } from "vitest";
+import { createCloudDevProxy } from "../../../scripts/lib/cloud-dev-proxy.mjs";
+
+describe("local Cloud proxy errors", () => {
+ it.each(["EPIPE", "ECONNRESET"])(
+ "does not crash when a WebSocket client closes with %s",
+ (code) => {
+ const reportError = vi.fn();
+ const proxy = createCloudDevProxy({ reportError });
+ const connection = { destroy: vi.fn() };
+ const error = Object.assign(new Error(`write ${code}`), { code });
+
+ expect(() => proxy.emit("error", error, {}, connection)).not.toThrow();
+ expect(connection.destroy).toHaveBeenCalledOnce();
+ expect(reportError).not.toHaveBeenCalled();
+ },
+ );
+
+ it("reports unexpected proxy errors without crashing", () => {
+ const reportError = vi.fn();
+ const proxy = createCloudDevProxy({ reportError });
+ const connection = { destroy: vi.fn() };
+
+ expect(() =>
+ proxy.emit("error", new Error("unexpected failure"), {}, connection),
+ ).not.toThrow();
+ expect(connection.destroy).toHaveBeenCalledOnce();
+ expect(reportError).toHaveBeenCalledWith(
+ "bb Cloud dev proxy: unexpected failure",
+ );
+ });
+});
diff --git a/packages/scripts/test/cloud-dev-readiness.test.mjs b/packages/scripts/test/cloud-dev-readiness.test.mjs
new file mode 100644
index 0000000000..c90bc17cdf
--- /dev/null
+++ b/packages/scripts/test/cloud-dev-readiness.test.mjs
@@ -0,0 +1,57 @@
+import { createServer } from "node:http";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { waitForCloudService } from "../../../scripts/lib/cloud-dev-readiness.mjs";
+
+let server;
+
+afterEach(async () => {
+ vi.useRealTimers();
+ await new Promise((resolve) => server?.close(resolve) ?? resolve());
+ server = undefined;
+});
+
+describe("local Cloud readiness", () => {
+ it("sends the routing Host header through a real Node HTTP request", async () => {
+ let receivedHost;
+ server = createServer((request, response) => {
+ receivedHost = request.headers.host;
+ response.writeHead(204).end();
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (typeof address !== "object" || address === null) {
+ throw new Error("test server did not bind a TCP port");
+ }
+
+ await waitForCloudService({
+ url: `http://127.0.0.1:${address.port}/dashboard`,
+ host: "bb.localhost:42745",
+ serviceExited: () => false,
+ });
+
+ expect(receivedHost).toBe("bb.localhost:42745");
+ });
+
+ it("backs off after a 500 response", async () => {
+ vi.useFakeTimers();
+ const requestImpl = vi
+ .fn()
+ .mockResolvedValueOnce(500)
+ .mockResolvedValueOnce(204);
+
+ const ready = waitForCloudService({
+ url: "http://127.0.0.1:42745/dashboard",
+ host: "bb.localhost:42745",
+ serviceExited: () => false,
+ timeoutMs: 1_000,
+ retryDelayMs: 250,
+ requestImpl,
+ });
+ await vi.advanceTimersByTimeAsync(249);
+ expect(requestImpl).toHaveBeenCalledTimes(1);
+ await vi.advanceTimersByTimeAsync(1);
+ await ready;
+
+ expect(requestImpl).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/scripts/test/dev-instance-expectations.ts b/packages/scripts/test/dev-instance-expectations.ts
index 2a35e92448..6dab6a75a6 100644
--- a/packages/scripts/test/dev-instance-expectations.ts
+++ b/packages/scripts/test/dev-instance-expectations.ts
@@ -3,6 +3,8 @@ import { isAbsolute, join, relative } from "node:path";
export interface ExpectedDevPortSet {
appPort: number;
+ cloudPort: number;
+ cloudWorkerPort: number;
hostDaemonPort: number;
serverPort: number;
}
@@ -26,10 +28,18 @@ function expectedPortOffset(repoRoot: string): number {
);
}
+function reservePackagedAppPorts(port: number): number {
+ if (port === 38_886) return 59_000;
+ if (port === 38_887) return 59_001;
+ return port;
+}
+
export function expectedDevPorts(repoRoot: string): ExpectedDevPortSet {
const offset = expectedPortOffset(repoRoot);
return {
appPort: 11_000 + offset,
+ cloudPort: reservePackagedAppPorts(35_000 + offset),
+ cloudWorkerPort: 43_000 + offset,
hostDaemonPort: 27_000 + offset,
serverPort: 19_000 + offset,
};
diff --git a/packages/scripts/test/run-dev.test.ts b/packages/scripts/test/run-dev.test.ts
index ccd2075f7f..5af4dbc75a 100644
--- a/packages/scripts/test/run-dev.test.ts
+++ b/packages/scripts/test/run-dev.test.ts
@@ -57,7 +57,7 @@ describe("run-dev", () => {
expect(config.dataDir).toBe(expectedDevDataDir({ homeDir, repoRoot }));
expect(config.ports).toEqual(expectedDevPorts(repoRoot));
expect(config.serverUrl).toBe(expectedDevServerUrl(repoRoot));
- expect(new Set(Object.values(config.ports))).toHaveLength(3);
+ expect(new Set(Object.values(config.ports))).toHaveLength(5);
expect(Object.values(config.ports)).not.toContain(5173);
expect(Object.values(config.ports)).not.toContain(3334);
expect(Object.values(config.ports)).not.toContain(3002);
@@ -65,6 +65,37 @@ describe("run-dev", () => {
expect(Object.values(config.ports)).not.toContain(38887);
});
+ it("keeps Cloud gateway ports out of the worker band and packaged ports", () => {
+ const rootsByOffset = new Map([
+ [0, "/repo/port-13604"],
+ [1, "/repo/port-3079"],
+ [3886, "/repo/port-3186"],
+ [3887, "/repo/port-6427"],
+ [7998, "/repo/port-57923"],
+ [7999, "/repo/port-7517"],
+ ]);
+ const portsByOffset = new Map(
+ [...rootsByOffset].map(([offset, repoRoot]) => [
+ offset,
+ resolveDevInstanceConfig({ homeDir: "/Users/tester", repoRoot }).ports,
+ ]),
+ );
+
+ expect(portsByOffset.get(3886)?.cloudPort).toBe(59000);
+ expect(portsByOffset.get(3887)?.cloudPort).toBe(59001);
+ expect(portsByOffset.get(7998)?.cloudPort).toBe(42998);
+ expect(portsByOffset.get(7999)?.cloudPort).toBe(42999);
+ expect(portsByOffset.get(0)?.cloudWorkerPort).toBe(43000);
+ expect(portsByOffset.get(1)?.cloudWorkerPort).toBe(43001);
+ expect(
+ new Set(
+ [...portsByOffset.values()].flatMap(
+ ({ cloudPort, cloudWorkerPort }) => [cloudPort, cloudWorkerPort],
+ ),
+ ),
+ ).toHaveLength(rootsByOffset.size * 2);
+ });
+
it("uses the home-relative checkout path for non-managed checkout paths", () => {
const homeDir = "/Users/tester";
const repoRoot = "/Users/tester/src/work/bb-feature-copy";
@@ -97,6 +128,9 @@ describe("run-dev", () => {
expect(env.BB_SERVER_URL).toBe(config.serverUrl);
expect(env.BB_HOST_DAEMON_PORT).toBe(String(config.ports.hostDaemonPort));
expect(env.BB_DEV_APP_PORT).toBe(String(config.ports.appPort));
+ expect(env.BB_DEV_CONNECT_BASE_URL).toBe(
+ `http://bb.localhost:${config.ports.cloudPort}`,
+ );
});
it("inherits parent bb skills for managed worktree dev apps", () => {
diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts
index acdee3cbbd..7078f2656e 100644
--- a/packages/templates/src/generated/templates.generated.ts
+++ b/packages/templates/src/generated/templates.generated.ts
@@ -50,7 +50,7 @@ export const templateDefinitions = [
},
{
"id": "bbGuideEnvironments",
- "body": "Environment commands\n\nEnvironments determine where threads run. Multiple threads can share an environment\n(e.g., a coding thread and a review thread in the same worktree).\n\nMaking your repo work with bb:\n\n Commit a .bb-env-setup.sh script at the repo root when new bb worktrees need\n repo-specific setup. After bb creates a new managed worktree environment, it\n looks for .bb-env-setup.sh inside that new workspace. If the file is absent,\n provisioning continues with no error.\n\n The script must be tracked by git. A fresh worktree only checks out tracked\n files, so an untracked .bb-env-setup.sh in your source checkout will not be\n present and will not run.\n\n BB runs the hook as `env bash .bb-env-setup.sh` with cwd set to the new\n workspace. POSIX shell setup scripts are not supported on Windows. The hook\n inherits the host daemon's sanitized environment: NODE_ENV and every BB_*\n variable are removed, and bb does not inject BB_PROJECT_ID, BB_ENVIRONMENT_ID,\n or BB_SOURCE_PATH.\n\n The hook runs only for newly-created managed worktree environments. It does\n not run for direct/project-checkout environments, personal scratch workspaces,\n or reconnecting an existing managed worktree.\n\n A non-zero exit, timeout, signal, or cancellation fails provisioning and bb\n removes the new worktree. Keep optional setup steps non-fatal inside the\n script if the environment should still open. Provisioning progress reports\n \"Running .bb-env-setup.sh\" and then \".bb-env-setup.sh finished\",\n \".bb-env-setup.sh failed\", or \".bb-env-setup.sh cancelled\".\n\n New worktrees do not contain untracked files such as .env.local. To copy\n them from the source checkout, commit a .worktreeinclude file at the repo\n root. It uses gitignore syntax: one pattern per line, # for comments, ! to\n negate an earlier pattern. bb copies each untracked file in the source\n checkout that matches a pattern:\n\n .env\n .env.*\n !.env.example\n certs/\n\n bb copies files only. It follows no symlinks, and it replaces nothing that\n the worktree already has. The copy runs after `git worktree add` and before\n .bb-env-setup.sh, so the setup script can read the copied files. A pattern\n that matches nothing, or a file bb cannot read, is reported in the\n provisioning transcript and does not fail provisioning.\n\n Large directories such as node_modules are copied file by file. Install\n dependencies in .bb-env-setup.sh instead of listing them here.\n\n For files that customize agent instructions and skills (AGENTS.md,\n .bb/AGENTS.md, .bb/skills/), run `bb guide agent-configuration`.\n\n bb environment show Show environment details (path, branch, status)\n\n bb environment status Show workspace status\n --merge-base-branch Include merge-base status\n\n bb environment branches List local and remote branches\n --query Filter branch names\n --limit Limit local and remote results\n\n bb environment paths Search workspace paths\n --query Fuzzy path query\n --limit Maximum results\n --files Include only files unless combined with --directories\n --directories Include only directories unless combined with --files\n\n bb environment diff Show file summary and full git diff\n bb environment diff-files List changed-file metadata\n --target uncommitted, branch_committed, all, or commit (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-file Read one side of a changed file\n --target Diff target (required)\n --path Repository-relative path (required)\n --side File side (required)\n --merge-base-ref Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-patch Fetch selected file patches\n --target Diff target (required)\n --path Changed path; repeat for multiple files (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment update Update environment metadata\n --merge-base-branch Set merge-base branch override\n --clear-merge-base-branch Clear merge-base override\n --name Set display name\n --clear-name Clear display name\n\n bb environment commit Create a commit in the environment\n\n bb environment squash-merge Squash-merge into a target branch\n --merge-base-branch Target branch (required)\n\n bb environment archive-threads Archive all threads in an environment\n\n bb environment pull-request show Inspect a pull request\n bb environment pull-request ready Mark a pull request ready\n bb environment pull-request draft Convert a pull request to draft\n bb environment pull-request merge Merge a pull request\n --method merge, squash, or rebase\n\nEvery inspection command accepts an arbitrary environment ID and supports\n`--json`. Non-git status/diff responses are reported explicitly. `diff-file`\nprints UTF-8 content directly and labels base64 binary content; diff and patch\ntruncation markers are preserved.\n\nRemote access (bb connect):\n\n Expose this bb server at .getbb.app so you can reach it from any\n browser. Claim a handle at https://getbb.app, copy the connect command it\n generates, then run it here to\n pair:\n\n bb connect --code --server https://.getbb.app\n --code One-time pairing code from the dashboard\n --server https://.getbb.app (from the dashboard)\n\n Pairing returns immediately: the bb SERVER redeems the code, stores the\n credential, and holds the tunnel itself — so it stays up as long as bb is\n running and reconnects on restart (no foreground process).\n Without an installed bb, pair via npm:\n `npx -p bb-app@latest bb connect --code --server `.\n\n bb connect status Show the server's connect status\n bb connect off Disconnect and forget the pairing\n bb connect expose [--host ] Share a host's HTTP port\n bb connect unexpose [--host ] Stop sharing on that host\n bb connect shares [--host ] List that host's shares\n bb connect servers List every bb on this account (handle, url, live)\n\n Port sharing works from threads on any enrolled host. In a thread,\n `bb connect expose ` resolves the thread environment's host; outside a\n thread it defaults to the server host. `--host ` overrides that\n choice for expose, unexpose, and shares. Server-host URLs use\n `https://--.getbb.app`; machine-host URLs use\n `https://--.getbb.app` and proxy directly through that\n machine's daemon. Access is owner-session-gated — only viewers signed into\n the owner's getbb.app account can open the URL; it is not a public internet\n link. Agents should run expose from the thread that started the server, share\n the returned URL, and unexpose from the same thread when it stops.\n `bb connect status` shows all shares with host + URL. `shares --json` returns\n the resolved `host` and rows with `hostId`, `hostName`, `port`, and `url`.\n\n Remote access is owned by the builtin \"connect\" plugin (Plugins → connect\n shows the URL, QR code, and shared ports). Disabling the plugin\n (`bb plugin disable connect`) cuts off all remote access; re-enable with\n `bb plugin enable connect`.",
+ "body": "Environment commands\n\nEnvironments determine where threads run. Multiple threads can share an environment\n(e.g., a coding thread and a review thread in the same worktree).\n\nMaking your repo work with bb:\n\n Commit a .bb-env-setup.sh script at the repo root when new bb worktrees need\n repo-specific setup. After bb creates a new managed worktree environment, it\n looks for .bb-env-setup.sh inside that new workspace. If the file is absent,\n provisioning continues with no error.\n\n The script must be tracked by git. A fresh worktree only checks out tracked\n files, so an untracked .bb-env-setup.sh in your source checkout will not be\n present and will not run.\n\n BB runs the hook as `env bash .bb-env-setup.sh` with cwd set to the new\n workspace. POSIX shell setup scripts are not supported on Windows. The hook\n inherits the host daemon's sanitized environment: NODE_ENV and every BB_*\n variable are removed, and bb does not inject BB_PROJECT_ID, BB_ENVIRONMENT_ID,\n or BB_SOURCE_PATH.\n\n The hook runs only for newly-created managed worktree environments. It does\n not run for direct/project-checkout environments, personal scratch workspaces,\n or reconnecting an existing managed worktree.\n\n A non-zero exit, timeout, signal, or cancellation fails provisioning and bb\n removes the new worktree. Keep optional setup steps non-fatal inside the\n script if the environment should still open. Provisioning progress reports\n \"Running .bb-env-setup.sh\" and then \".bb-env-setup.sh finished\",\n \".bb-env-setup.sh failed\", or \".bb-env-setup.sh cancelled\".\n\n New worktrees do not contain untracked files such as .env.local. To copy\n them from the source checkout, commit a .worktreeinclude file at the repo\n root. It uses gitignore syntax: one pattern per line, # for comments, ! to\n negate an earlier pattern. bb copies each untracked file in the source\n checkout that matches a pattern:\n\n .env\n .env.*\n !.env.example\n certs/\n\n bb copies files only. It follows no symlinks, and it replaces nothing that\n the worktree already has. The copy runs after `git worktree add` and before\n .bb-env-setup.sh, so the setup script can read the copied files. A pattern\n that matches nothing, or a file bb cannot read, is reported in the\n provisioning transcript and does not fail provisioning.\n\n Large directories such as node_modules are copied file by file. Install\n dependencies in .bb-env-setup.sh instead of listing them here.\n\n For files that customize agent instructions and skills (AGENTS.md,\n .bb/AGENTS.md, .bb/skills/), run `bb guide agent-configuration`.\n\n bb environment show Show environment details (path, branch, status)\n\n bb environment status Show workspace status\n --merge-base-branch Include merge-base status\n\n bb environment branches List local and remote branches\n --query Filter branch names\n --limit Limit local and remote results\n\n bb environment paths Search workspace paths\n --query Fuzzy path query\n --limit Maximum results\n --files Include only files unless combined with --directories\n --directories Include only directories unless combined with --files\n\n bb environment diff Show file summary and full git diff\n bb environment diff-files List changed-file metadata\n --target uncommitted, branch_committed, all, or commit (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-file Read one side of a changed file\n --target Diff target (required)\n --path Repository-relative path (required)\n --side File side (required)\n --merge-base-ref Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-patch Fetch selected file patches\n --target Diff target (required)\n --path Changed path; repeat for multiple files (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment update Update environment metadata\n --merge-base-branch Set merge-base branch override\n --clear-merge-base-branch Clear merge-base override\n --name Set display name\n --clear-name Clear display name\n\n bb environment commit Create a commit in the environment\n\n bb environment squash-merge Squash-merge into a target branch\n --merge-base-branch Target branch (required)\n\n bb environment archive-threads Archive all threads in an environment\n\n bb environment pull-request show Inspect a pull request\n bb environment pull-request ready Mark a pull request ready\n bb environment pull-request draft Convert a pull request to draft\n bb environment pull-request merge Merge a pull request\n --method merge, squash, or rebase\n\nEvery inspection command accepts an arbitrary environment ID and supports\n`--json`. Non-git status/diff responses are reported explicitly. `diff-file`\nprints UTF-8 content directly and labels base64 binary content; diff and patch\ntruncation markers are preserved.\n\nRemote access (bb connect):\n\n Expose this bb server at .getbb.app so you can reach it from any\n browser. Claim a handle at https://getbb.app, copy the connect command it\n generates, then run it here to\n pair:\n\n bb connect --code --server https://.getbb.app\n --code One-time pairing code from the dashboard\n --server https://.getbb.app (from the dashboard)\n\n Pairing returns immediately: the bb SERVER redeems the code, stores the\n credential, and holds the tunnel itself — so it stays up as long as bb is\n running and reconnects on restart (no foreground process).\n Without an installed bb, pair via npm:\n `npx -p bb-app@latest bb connect --code --server `.\n\n In a source checkout, `pnpm dev` automatically points the unpaired Connect\n settings and code-only pairing at that worktree's local Cloud origin through\n `BB_DEV_CONNECT_BASE_URL`. Explicit `--server` and `--base-url` targets still\n win, so the dev bb can also pair with getbb.app.\n\n bb connect status Show the server's connect status\n bb connect off Disconnect and forget the pairing\n bb connect expose [--host ] Share a host's HTTP port\n bb connect unexpose [--host ] Stop sharing on that host\n bb connect shares [--host ] List that host's shares\n bb connect servers List every bb on this account (handle, url, live)\n\n Port sharing works from threads on any enrolled host. In a thread,\n `bb connect expose ` resolves the thread environment's host; outside a\n thread it defaults to the server host. `--host ` overrides that\n choice for expose, unexpose, and shares. Server-host URLs use\n `https://--.getbb.app`; machine-host URLs use\n `https://--.getbb.app` and proxy directly through that\n machine's daemon. Access is owner-session-gated — only viewers signed into\n the owner's getbb.app account can open the URL; it is not a public internet\n link. Agents should run expose from the thread that started the server, share\n the returned URL, and unexpose from the same thread when it stops.\n `bb connect status` shows all shares with host + URL. `shares --json` returns\n the resolved `host` and rows with `hostId`, `hostName`, `port`, and `url`.\n\n Remote access is owned by the builtin \"connect\" plugin (Plugins → connect\n shows the URL, QR code, and shared ports). Disabling the plugin\n (`bb plugin disable connect`) cuts off all remote access; re-enable with\n `bb plugin enable connect`.",
"fileName": "bb-guide-environments.md",
"kind": "instruction",
"title": "bb Guide — Environments",
diff --git a/packages/templates/src/templates/bb-guide-environments.md b/packages/templates/src/templates/bb-guide-environments.md
index d205fa2375..989676af55 100644
--- a/packages/templates/src/templates/bb-guide-environments.md
+++ b/packages/templates/src/templates/bb-guide-environments.md
@@ -135,6 +135,11 @@ Remote access (bb connect):
Without an installed bb, pair via npm:
`npx -p bb-app@latest bb connect --code --server `.
+ In a source checkout, `pnpm dev` automatically points the unpaired Connect
+ settings and code-only pairing at that worktree's local Cloud origin through
+ `BB_DEV_CONNECT_BASE_URL`. Explicit `--server` and `--base-url` targets still
+ win, so the dev bb can also pair with getbb.app.
+
bb connect status Show the server's connect status
bb connect off Disconnect and forget the pairing
bb connect expose [--host ] Share a host's HTTP port
diff --git a/plugins/connect/app.test.tsx b/plugins/connect/app.test.tsx
index 673be1087d..996a6201ab 100644
--- a/plugins/connect/app.test.tsx
+++ b/plugins/connect/app.test.tsx
@@ -58,6 +58,22 @@ describe("connect settings section", () => {
expect(app.settingsSections[0]?.title).toBeUndefined();
});
+ it("uses the local Cloud dashboard supplied by the server", async () => {
+ const dashboardUrl = "http://bb.localhost:42745/dashboard";
+ const slot = renderSlot(
+ app.settingsSections[0]!,
+ {},
+ { rpc: { status: () => status({ dashboardUrl }) } },
+ );
+
+ const link = (await slot.findByRole("link", {
+ name: "Get a connect code",
+ })) as HTMLAnchorElement;
+ expect(link.href).toBe(dashboardUrl);
+ slot.getByText("you.bb.localhost:42745");
+ slot.getByText(/your bb\.localhost:42745 dashboard/);
+ });
+
it("auto-submits a normalized 4-4 code and applies live paired status", async () => {
let currentStatus = status();
const slot = renderSlot(
diff --git a/plugins/connect/app.tsx b/plugins/connect/app.tsx
index 0fdb8fec92..4868e6755d 100644
--- a/plugins/connect/app.tsx
+++ b/plugins/connect/app.tsx
@@ -78,7 +78,7 @@ const PAIR_ERROR_COPY: Record = {
tail: " — each code works once.",
},
network: {
- lead: "Couldn't reach getbb.app.",
+ lead: "Couldn't reach the Connect service.",
linkLabel: "Open the dashboard",
tail: " — check your connection, then try again.",
},
@@ -745,12 +745,14 @@ function DisconnectDialog({
open,
onOpenChange,
host,
+ dashboardHost,
pending,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
host: string;
+ dashboardHost: string;
pending: boolean;
onConfirm: () => void;
}) {
@@ -765,7 +767,7 @@ function DisconnectDialog({
{host} will
stop working on all devices. Re-pairing needs a new code from
- your getbb.app dashboard.
+ your {dashboardHost} dashboard.