From bbf0b3457107ba972a79d910a6b25c3a9a5b7e49 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 7 Aug 2026 14:59:46 -0700 Subject: [PATCH 1/2] Add a usable local Cloud development workflow --- apps/app/src/lib/dev-websocket-url.test.ts | 20 +- apps/app/src/lib/dev-websocket-url.ts | 26 +- apps/app/src/vite-env.d.ts | 1 + apps/app/vite.dev.config.ts | 2 + apps/connect/.dev.vars.example | 5 + apps/connect/src/auth-cookie.test.ts | 92 +++ apps/connect/src/auth-cookie.ts | 104 +++ apps/connect/src/machine-label.ts | 3 +- apps/connect/src/protocol-headers.ts | 5 + apps/connect/src/servers.test.ts | 26 +- apps/connect/src/servers.ts | 8 +- apps/connect/src/tunnel-do.ts | 15 +- apps/connect/src/worker.test.ts | 50 ++ apps/connect/src/worker.ts | 55 +- .../skills/builtin-skills/bb-cli/SKILL.md | 7 + apps/web/.dev.vars.example | 5 + apps/web/src/routes/dashboard.tsx | 47 +- apps/web/src/server/api.test.ts | 63 ++ apps/web/src/server/api.ts | 107 ++- apps/web/src/server/auth-runtime.test.ts | 78 +++ apps/web/src/server/auth-runtime.ts | 80 +++ apps/web/src/server/auth.ts | 11 +- apps/web/src/server/current-user.server.ts | 7 +- apps/web/src/server/env.ts | 11 + apps/web/vite.config.ts | 33 +- docs/configuration.md | 33 +- docs/debugging-and-qa.md | 70 ++ package.json | 1 + packages/config/src/runtime.ts | 18 + packages/connect-db/src/constants.ts | 31 +- packages/connect-db/test/schema.test.ts | 10 + .../scripts/test/dev-instance-expectations.ts | 12 + packages/scripts/test/run-dev.test.ts | 2 +- .../src/generated/templates.generated.ts | 2 +- .../src/templates/bb-guide-environments.md | 7 + plugins/connect/src/cli.ts | 1 + plugins/connect/src/connect.test.ts | 115 ++++ plugins/connect/src/redeem.ts | 120 +++- plugins/connect/src/server.ts | 11 +- plugins/connect/src/tunnel.ts | 15 +- scripts/bb-cloud-dev.mjs | 621 ++++++++++++++++++ 41 files changed, 1804 insertions(+), 126 deletions(-) create mode 100644 apps/connect/.dev.vars.example create mode 100644 apps/connect/src/auth-cookie.test.ts create mode 100644 apps/connect/src/auth-cookie.ts create mode 100644 apps/connect/src/protocol-headers.ts create mode 100644 apps/web/.dev.vars.example create mode 100644 apps/web/src/server/auth-runtime.test.ts create mode 100644 apps/web/src/server/auth-runtime.ts create mode 100644 scripts/bb-cloud-dev.mjs diff --git a/apps/app/src/lib/dev-websocket-url.test.ts b/apps/app/src/lib/dev-websocket-url.test.ts index b8c5ecb0de..bbc6feab02 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 the HTTP localhost Cloud gate", () => { + vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802); + vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802); + installWindowLocation("http://sawyer.localhost:39802/threads/thr_1"); + + expect(buildDevWebSocketUrl({ path: "/ws" })).toBe( + "ws://sawyer.localhost:39802/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..3be5811e13 100644 --- a/apps/app/src/lib/dev-websocket-url.ts +++ b/apps/app/src/lib/dev-websocket-url.ts @@ -2,25 +2,37 @@ 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:") { + // A dev app reached on a port other than Vite's own listener is behind a + // reverse proxy or bb connect. Its 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. This covers both HTTPS deployments and the + // HTTP *.localhost gate used by pnpm cloud:dev. + 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 84d1569500..6f8da5f488 100644 --- a/apps/app/vite.dev.config.ts +++ b/apps/app/vite.dev.config.ts @@ -6,6 +6,7 @@ const viteDevConfig = loadViteDevConfig(); const devWebSocketBrowserHostPortDefine = JSON.stringify( viteDevConfig.serverWsOrigin.port, ); +const devAppBrowserHostPortDefine = JSON.stringify(viteDevConfig.appPort); export default defineConfig({ ...sharedViteConfig, @@ -13,6 +14,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__: devAppBrowserHostPortDefine, }, server: { host: viteDevConfig.appHost, diff --git a/apps/connect/.dev.vars.example b/apps/connect/.dev.vars.example new file mode 100644 index 0000000000..0dde9c2d61 --- /dev/null +++ b/apps/connect/.dev.vars.example @@ -0,0 +1,5 @@ +# Used only by the local Connect worker. Never commit real secret values. +OPENAI_API_KEY=replace-with-openai-api-key + +# Generate a local value with: openssl rand -hex 32 +BETTER_AUTH_SECRET=replace-with-random-secret diff --git a/apps/connect/src/auth-cookie.test.ts b/apps/connect/src/auth-cookie.test.ts new file mode 100644 index 0000000000..44998dd211 --- /dev/null +++ b/apps/connect/src/auth-cookie.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + LOCAL_BETTER_AUTH_SESSION_COOKIE, + SECURE_BETTER_AUTH_SESSION_COOKIE, + resolveBetterAuthSessionCookieName, + resolveConnectAuthRuntime, + resolveConnectRequestHost, + stripConnectDevRoutingHeaders, +} from "./auth-cookie.js"; + +describe("Better Auth session cookie configuration", () => { + it("defaults to the production HTTPS cookie", () => { + expect(resolveBetterAuthSessionCookieName(undefined)).toBe( + SECURE_BETTER_AUTH_SESSION_COOKIE, + ); + }); + + it("accepts the explicit loopback HTTP cookie", () => { + expect( + resolveBetterAuthSessionCookieName(LOCAL_BETTER_AUTH_SESSION_COOKIE), + ).toBe(LOCAL_BETTER_AUTH_SESSION_COOKIE); + }); + + it("rejects arbitrary cookie names", () => { + expect(() => resolveBetterAuthSessionCookieName("attacker-cookie")).toThrow( + /supported Better Auth session cookie/u, + ); + }); +}); + +describe("Connect auth runtime", () => { + it("defaults production to the HTTPS account apex", () => { + expect(resolveConnectAuthRuntime({ BASE_DOMAIN: "getbb.app" })).toEqual({ + accountAppUrl: "https://getbb.app", + devAuthUserId: null, + }); + }); + + it("allows the seeded identity only on the local split-service topology", () => { + expect( + resolveConnectAuthRuntime({ + BASE_DOMAIN: "localhost", + ACCOUNT_APP_URL: "http://127.0.0.1:8792", + DEV_AUTH_USER_ID: "usr_cloud_dev", + }), + ).toEqual({ + accountAppUrl: "http://127.0.0.1:8792", + devAuthUserId: "usr_cloud_dev", + }); + }); + + it("rejects a seeded identity on deployed domains", () => { + expect(() => + resolveConnectAuthRuntime({ + BASE_DOMAIN: "getbb.app", + ACCOUNT_APP_URL: "https://getbb.app", + DEV_AUTH_USER_ID: "usr_cloud_dev", + }), + ).toThrow("only allowed for a localhost gate"); + }); +}); + +describe("local routing host", () => { + it("accepts a proxy-preserved host only with the per-run token", () => { + const headers = new Headers({ + host: "getbb.app", + "x-bb-cloud-dev-routing-label": "sawyer", + "x-bb-cloud-dev-token": "secret", + }); + expect( + resolveConnectRequestHost(headers, { + BASE_DOMAIN: "localhost", + DEV_ROUTING_TOKEN: "secret", + }), + ).toBe("sawyer.localhost"); + expect( + resolveConnectRequestHost(headers, { + BASE_DOMAIN: "localhost", + DEV_ROUTING_TOKEN: "different", + }), + ).toBe("getbb.app"); + }); + + it("strips local routing headers before origin forwarding", () => { + const headers = new Headers({ + "x-bb-cloud-dev-routing-label": "sawyer", + "x-bb-cloud-dev-token": "secret", + }); + stripConnectDevRoutingHeaders(headers); + expect([...headers]).toEqual([]); + }); +}); diff --git a/apps/connect/src/auth-cookie.ts b/apps/connect/src/auth-cookie.ts new file mode 100644 index 0000000000..f7893d220e --- /dev/null +++ b/apps/connect/src/auth-cookie.ts @@ -0,0 +1,104 @@ +export const SECURE_BETTER_AUTH_SESSION_COOKIE = + "__Secure-better-auth.session_token"; +export const LOCAL_BETTER_AUTH_SESSION_COOKIE = "better-auth.session_token"; + +/** + * Production defaults to Better Auth's HTTPS cookie. The local Cloud launcher + * explicitly selects the HTTP cookie name emitted for its loopback APP_URL. + */ +export function resolveBetterAuthSessionCookieName( + configuredName: string | undefined, +): string { + if (configuredName === undefined) return SECURE_BETTER_AUTH_SESSION_COOKIE; + if ( + configuredName === SECURE_BETTER_AUTH_SESSION_COOKIE || + configuredName === LOCAL_BETTER_AUTH_SESSION_COOKIE + ) { + return configuredName; + } + throw new Error( + "BETTER_AUTH_SESSION_COOKIE_NAME must name a supported Better Auth session cookie", + ); +} + +export interface ConnectAuthRuntime { + accountAppUrl: string; + devAuthUserId: string | null; +} + +const DEV_ROUTING_LABEL_HEADER = "x-bb-cloud-dev-routing-label"; +const DEV_ROUTING_TOKEN_HEADER = "x-bb-cloud-dev-token"; + +/** + * Resolve the gate's account origin and tightly-scoped local auth bypass once + * at the Worker boundary. A seeded identity can never be enabled for a + * deployed domain or a non-loopback account origin. + */ +export function resolveConnectAuthRuntime(env: { + BASE_DOMAIN: string; + ACCOUNT_APP_URL?: string; + DEV_AUTH_USER_ID?: string; +}): ConnectAuthRuntime { + const configuredAppUrl = env.ACCOUNT_APP_URL?.trim(); + const accountUrl = new URL(configuredAppUrl || `https://${env.BASE_DOMAIN}`); + if ( + (accountUrl.protocol !== "http:" && accountUrl.protocol !== "https:") || + accountUrl.username !== "" || + accountUrl.password !== "" || + accountUrl.pathname !== "/" || + accountUrl.search !== "" || + accountUrl.hash !== "" + ) { + throw new Error("ACCOUNT_APP_URL must be an HTTP(S) origin"); + } + + const configuredUserId = env.DEV_AUTH_USER_ID?.trim(); + if (!configuredUserId) { + return { accountAppUrl: accountUrl.origin, devAuthUserId: null }; + } + const loopbackAccount = + accountUrl.protocol === "http:" && + (accountUrl.hostname === "127.0.0.1" || + accountUrl.hostname === "localhost" || + accountUrl.hostname === "::1"); + if (env.BASE_DOMAIN !== "localhost" || !loopbackAccount) { + throw new Error( + "DEV_AUTH_USER_ID is only allowed for a localhost gate and loopback account origin", + ); + } + return { + accountAppUrl: accountUrl.origin, + devAuthUserId: configuredUserId, + }; +} + +/** + * Wrangler rewrites local request hosts to one configured upstream. The + * launcher-owned loopback proxy preserves the browser host behind a per-run + * token; deployed requests always use the ordinary Host header. + */ +export function resolveConnectRequestHost( + headers: Headers, + env: { + BASE_DOMAIN: string; + DEV_ROUTING_TOKEN?: string; + }, +): string { + const ordinaryHost = headers.get("host") ?? ""; + const configuredToken = env.DEV_ROUTING_TOKEN?.trim(); + if (!configuredToken) return ordinaryHost; + if (env.BASE_DOMAIN !== "localhost") { + throw new Error("DEV_ROUTING_TOKEN is only allowed for the localhost gate"); + } + if (headers.get(DEV_ROUTING_TOKEN_HEADER) !== configuredToken) { + return ordinaryHost; + } + const routingLabel = headers.get(DEV_ROUTING_LABEL_HEADER); + return routingLabel ? `${routingLabel}.${env.BASE_DOMAIN}` : ordinaryHost; +} + +/** Never expose launcher-only routing proof to a tunneled bb origin. */ +export function stripConnectDevRoutingHeaders(headers: Headers): void { + headers.delete(DEV_ROUTING_LABEL_HEADER); + headers.delete(DEV_ROUTING_TOKEN_HEADER); +} diff --git a/apps/connect/src/machine-label.ts b/apps/connect/src/machine-label.ts index 134c797a0a..765ae99024 100644 --- a/apps/connect/src/machine-label.ts +++ b/apps/connect/src/machine-label.ts @@ -9,8 +9,7 @@ import { } from "@bb/connect-db"; import { verifyMachineCredentialDetails } from "./session.js"; import type { Env } from "./tunnel-do.js"; - -const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine"; +import { MACHINE_CREDENTIAL_HEADER } from "./protocol-headers.js"; function fallbackLabel(machineId: string): string { const idPrefix = machineId diff --git a/apps/connect/src/protocol-headers.ts b/apps/connect/src/protocol-headers.ts new file mode 100644 index 0000000000..4711f3633f --- /dev/null +++ b/apps/connect/src/protocol-headers.ts @@ -0,0 +1,5 @@ +/** Internal headers used across the Connect gate and tunnel. */ +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..d3edb9bae8 100644 --- a/apps/connect/src/servers.test.ts +++ b/apps/connect/src/servers.test.ts @@ -23,6 +23,7 @@ import { verifyDesktopSessionCookie, verifyServerCredential, } from "./servers.js"; +import { SECURE_BETTER_AUTH_SESSION_COOKIE } from "./auth-cookie.js"; import { assignMachineLabel, assignMachineLabelForCredential, @@ -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_BETTER_AUTH_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,14 @@ 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_BETTER_AUTH_SESSION_COOKIE, + ), + ).toBeNull(); }); it("accepts a valid owner session cookie", async () => { @@ -303,7 +316,14 @@ 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_BETTER_AUTH_SESSION_COOKIE, + ), + ).toBe("acct-a"); }); }); diff --git a/apps/connect/src/servers.ts b/apps/connect/src/servers.ts index 3294bac531..6c943bc1d7 100644 --- a/apps/connect/src/servers.ts +++ b/apps/connect/src/servers.ts @@ -13,7 +13,6 @@ import { } from "./session.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; @@ -167,6 +166,7 @@ export async function resolveAccountUserId( request: Request, secret: string, db: ConnectDb, + sessionCookieName: string, ): Promise { const presented = request.headers.get("x-bb-connect-machine") ?? ""; if (presented) { @@ -179,7 +179,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); } @@ -237,6 +237,7 @@ export async function listAccountServers( export async function handleListAccountServers( request: Request, env: Env, + sessionCookieName: string, ): Promise { if (request.method !== "GET") { return new Response(JSON.stringify({ error: "method_not_allowed" }), { @@ -253,6 +254,7 @@ export async function handleListAccountServers( request, env.BETTER_AUTH_SECRET, db, + sessionCookieName, ); if (!userId) { return new Response(JSON.stringify({ error: "unauthorized" }), { @@ -272,6 +274,7 @@ export async function handleListAccountServers( export async function handleCreateDesktopSession( request: Request, env: Env, + sessionCookieName: string, ): Promise { if (request.method !== "POST") { return new Response(JSON.stringify({ error: "method_not_allowed" }), { @@ -287,6 +290,7 @@ export async function handleCreateDesktopSession( request, env.BETTER_AUTH_SECRET, db, + sessionCookieName, ); if (!userId) { return new Response(JSON.stringify({ error: "unauthorized" }), { diff --git a/apps/connect/src/tunnel-do.ts b/apps/connect/src/tunnel-do.ts index c65df2953f..1effb6d1c0 100644 --- a/apps/connect/src/tunnel-do.ts +++ b/apps/connect/src/tunnel-do.ts @@ -10,6 +10,7 @@ import { type Frame, type HeaderPair, } from "@bb/tunnel-contract"; +import { TUNNEL_TARGET_HEADER } from "./protocol-headers.js"; import { relayedResponse } from "./response-encoding.js"; export interface Env { @@ -17,6 +18,17 @@ export interface Env { DB: D1Database; BASE_DOMAIN: string; BETTER_AUTH_SECRET: string; + /** Account/dashboard origin when it is not the HTTPS BASE_DOMAIN apex. */ + ACCOUNT_APP_URL?: string; + /** Seeded local identity; accepted only for loopback `.localhost` QA. */ + DEV_AUTH_USER_ID?: string; + /** Per-launch secret used by the loopback proxy to preserve wildcard hosts. */ + DEV_ROUTING_TOKEN?: string; + /** + * Better Auth's loopback HTTP cookie lacks the production `__Secure-` + * prefix. Omitted in deployed environments; set by the local launcher. + */ + BETTER_AUTH_SESSION_COOKIE_NAME?: string; } const TUNNEL_TAG = "tunnel"; @@ -26,9 +38,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..4343112fa9 100644 --- a/apps/connect/src/worker.test.ts +++ b/apps/connect/src/worker.test.ts @@ -8,6 +8,9 @@ import { GATE_AUTH_HEADER, GATE_MACHINE_ID_HEADER, TUNNEL_TARGET_HEADER, +} from "./protocol-headers"; +import { LOCAL_BETTER_AUTH_SESSION_COOKIE } from "./auth-cookie"; +import { cacheNamespace, dashboardSignInUrl, requestForTunnelDo, @@ -674,6 +677,26 @@ describe("gate worker share hosts", () => { ); }); + it("reads the loopback Better Auth cookie selected by local development", async () => { + const { env, ctx } = makeEnv(() => new Response("ok")); + const localEnv = { + ...env, + BETTER_AUTH_SESSION_COOKIE_NAME: LOCAL_BETTER_AUTH_SESSION_COOKIE, + }; + + const response = await worker.fetch( + visitorRequest("sawyer.getbb.app", "/"), + localEnv as never, + ctx, + ); + + expect(response.status).toBe(200); + expect(mockParseCookie).toHaveBeenCalledWith( + null, + LOCAL_BETTER_AUTH_SESSION_COOKIE, + ); + }); + it("renders a bare machine-label page without proxying to the DO", async () => { mockResolveLabel.mockResolvedValue(resolvedMachine()); mockParseCookie.mockReturnValue(null); @@ -848,6 +871,33 @@ describe("gate worker share hosts", () => { expect(captured).toHaveLength(0); }); + it("routes a per-label localhost URL through the seeded local identity", async () => { + mockParseCookie.mockReturnValue(null); + mockResolveLabel.mockResolvedValue(resolvedServer()); + const { env, ctx, captured } = makeEnv(() => new Response("local bb")); + Object.assign(env, { + BASE_DOMAIN: "localhost", + ACCOUNT_APP_URL: "http://127.0.0.1:8792", + DEV_AUTH_USER_ID: OWNER, + }); + + const response = await worker.fetch( + visitorRequest("sawyer.localhost:8791", "/"), + env as never, + ctx, + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("local bb"); + expect(mockResolveLabel).toHaveBeenCalledWith( + "sawyer", + expect.anything(), + undefined, + ); + expect(mockVerifySession).not.toHaveBeenCalled(); + expect(captured).toHaveLength(1); + }); + 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..8c7f236a9b 100644 --- a/apps/connect/src/worker.ts +++ b/apps/connect/src/worker.ts @@ -17,17 +17,21 @@ import { import { serveWithCache } from "./cache.js"; import { BB_ICON_DATA_URI } from "./bb-icon.js"; import { handleAssignMachineLabel } from "./machine-label.js"; +import { + GATE_AUTH_HEADER, + GATE_MACHINE_ID_HEADER, + MACHINE_CREDENTIAL_HEADER, + TUNNEL_TARGET_HEADER, +} from "./protocol-headers.js"; +import { + resolveBetterAuthSessionCookieName, + resolveConnectAuthRuntime, + resolveConnectRequestHost, + stripConnectDevRoutingHeaders, +} from "./auth-cookie.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", @@ -223,6 +227,7 @@ export function requestForTunnelDo( headers.delete(MACHINE_CREDENTIAL_HEADER); headers.delete(GATE_AUTH_HEADER); headers.delete(GATE_MACHINE_ID_HEADER); + stripConnectDevRoutingHeaders(headers); if (target !== null) { headers.set(TUNNEL_TARGET_HEADER, target); } @@ -266,20 +271,24 @@ export default { ctx: ExecutionContext, ): Promise { const url = new URL(request.url); + const sessionCookieName = resolveBetterAuthSessionCookieName( + env.BETTER_AUTH_SESSION_COOKIE_NAME, + ); + const authRuntime = resolveConnectAuthRuntime(env); // 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. if (url.pathname === "/api/connect/servers") { - return handleListAccountServers(request, env); + return handleListAccountServers(request, env, sessionCookieName); } if (url.pathname === "/api/connect/desktop-session") { - return handleCreateDesktopSession(request, env); + return handleCreateDesktopSession(request, env, sessionCookieName); } if (url.pathname === "/api/connect/machine-label") { return handleAssignMachineLabel(request, env); } - const host = request.headers.get("host") ?? url.host; + const host = resolveConnectRequestHost(request.headers, env); 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 +300,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}`, + `${authRuntime.accountAppUrl}${url.pathname}${url.search}`, 301, ); } @@ -342,7 +351,11 @@ export default { } else { forward.searchParams.set("machineId", owner.id); } - return stub.fetch(new Request(forward, request)); + const headers = new Headers(request.headers); + stripConnectDevRoutingHeaders(headers); + return stub.fetch( + new Request(new Request(forward, request), { headers }), + ); } // Reserve the /__ namespace: never proxy internal paths from outside. @@ -368,6 +381,7 @@ export default { headers.delete(TUNNEL_TARGET_HEADER); headers.delete(GATE_AUTH_HEADER); headers.delete(GATE_MACHINE_ID_HEADER); + stripConnectDevRoutingHeaders(headers); return stub.fetch(new Request(request, { headers })); } @@ -401,6 +415,7 @@ export default { headers.delete(TUNNEL_TARGET_HEADER); headers.delete(GATE_AUTH_HEADER); headers.delete(GATE_MACHINE_ID_HEADER); + stripConnectDevRoutingHeaders(headers); headers.set(GATE_AUTH_HEADER, "machine"); headers.set(GATE_MACHINE_ID_HEADER, verified.machineId); return stub.fetch(new Request(request, { headers })); @@ -413,14 +428,16 @@ export default { // Identical auth for bare-label and share hosts. Because this check passed, // only the owner ever reaches the DO below (and thus its offline 503). const cookieHeader = request.headers.get("cookie"); - const cookie = parseCookie(cookieHeader, SESSION_COOKIE); + const cookie = parseCookie(cookieHeader, sessionCookieName); const desktopCookie = parseCookie(cookieHeader, DESKTOP_SESSION_COOKIE); - const appUrl = `https://${env.BASE_DOMAIN}`; - if (!cookie && !desktopCookie) + const appUrl = authRuntime.accountAppUrl; + if (!authRuntime.devAuthUserId && !cookie && !desktopCookie) return signInPage(label, appUrl, url.toString()); - const sessionUserId = cookie - ? await verifySessionCookie(cookie, env.BETTER_AUTH_SECRET, db) - : null; + const sessionUserId = + authRuntime.devAuthUserId ?? + (cookie + ? await verifySessionCookie(cookie, env.BETTER_AUTH_SECRET, db) + : null); const desktopUserId = desktopCookie ? await verifyDesktopSessionCookie(desktopCookie, env.BETTER_AUTH_SECRET) : null; diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 46f4ff4eba..adf482bd56 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -179,6 +179,13 @@ isolated|reuse`, or anchor with `--source-seq-end`. Permission mode inherits cuts it off entirely; with bb connect still enabled, `bb plugin enable connect` restores the command. Plugins → Connect shows the current URL, QR code, shared ports, re-pair form, and disconnect control. + `BB_CONNECT_BASE_URL` overrides the account and pairing-code redemption + origin for alternate deployments and local QA; leave it unset for + `https://getbb.app`. The service returns the authoritative tunnel-gate URL, + so it may differ from this account origin. + `BB_CONNECT_LOOPBACK_URL` is a source-QA-only loopback HTTP origin served by + the bare handle URL. Leave it unset outside source development; + `pnpm cloud:dev` manages it automatically. - Add remote execution machines from Settings → Machines. Its one-line installer stores the bb connect machine credential locally and configures both the daemon protocol and agent-launched `bb` CLI to traverse the account diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example new file mode 100644 index 0000000000..3d1ced7a45 --- /dev/null +++ b/apps/web/.dev.vars.example @@ -0,0 +1,5 @@ +# Optional: only needed for `pnpm cloud:dev -- --github-auth`. +# Create a separate local GitHub OAuth App with this callback URL: +# http://127.0.0.1/api/auth/callback/github +GITHUB_CLIENT_ID=replace-with-local-oauth-client-id +GITHUB_CLIENT_SECRET=replace-with-local-oauth-client-secret diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx index ce966c5347..b3055a19e1 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/dashboard.tsx @@ -328,7 +328,8 @@ function Home() { }, [data.authed, search.returnTo]); if (!data.authed) return ; - if (!data.handle) return ; + if (!data.handle) + return ; return ; } @@ -361,7 +362,7 @@ function SignInView({ returnTo }: { returnTo: string | undefined }) { /* ── shared claim field (W2 handle + M2 label) ────────────────────── */ function ClaimField({ - baseDomain, + serverUrlTemplate, initial = "", autoFocus, previewLead = "Your bb will live at", @@ -371,7 +372,7 @@ function ClaimField({ cancelLabel = "Cancel", layout, }: { - baseDomain: string; + serverUrlTemplate: string; initial?: string; autoFocus?: boolean; previewLead?: string; @@ -409,7 +410,8 @@ function ClaimField({ const error = submitError ?? (avail ? availabilityCopy(avail) : null); const canSubmit = !busy && !!label && (avail?.available ?? false); - const preview = `https://${label || "you"}.${baseDomain}`; + const preview = serverUrlTemplate.replace("{label}", label || "you"); + const addressSuffix = serverUrlTemplate.split("{label}")[1] ?? ""; async function submit() { if (!canSubmit) return; @@ -448,7 +450,7 @@ function ClaimField({ aria-label="Address" /> - .{baseDomain} + {addressSuffix}

@@ -474,7 +476,7 @@ function ClaimField({ /* ── W2: claim handle ─────────────────────────────────────────────── */ -function ClaimView({ baseDomain }: { baseDomain: string }) { +function ClaimView({ serverUrlTemplate }: { serverUrlTemplate: string }) { const router = useRouter(); return ( @@ -488,9 +490,11 @@ function ClaimView({ baseDomain }: { baseDomain: string }) {

- l ? `Claim ${l}.${baseDomain}` : "Claim your address" + l + ? `Claim ${serverUrlTemplate.replace("{label}", l).replace(/^https?:\/\//u, "")}` + : "Claim your address" } onClaim={async (label) => { const r = await claimHandleFn({ data: label }); @@ -541,7 +545,7 @@ function SetupCodePanel({ }, [code, fetchCode]); const cli = code - ? `npx -p bb-app@latest bb connect --code ${code.code} --server ${code.serverUrl}` + ? `npx -p bb-app@latest bb connect --code ${code.code} --server ${code.serverUrl} --base-url ${code.baseUrl}` : ""; return ( @@ -726,11 +730,9 @@ function RowMenu({ function ServerRow({ server, - baseDomain, autoPair, }: { server: ServerSummary; - baseDomain: string; /** First-run: the sole never-paired bb opens its pair panel by default. */ autoPair?: boolean; }) { @@ -780,10 +782,7 @@ function ServerRow({ - {server.subdomain} - - .{baseDomain} - + {server.serverUrl.replace(/^https?:\/\//u, "")} {server.online ? ( @@ -919,7 +918,7 @@ function ConnectAnotherDialog({ `Claim ${l || "…"}`} @@ -940,7 +939,7 @@ function ConnectAnotherDialog({

Pair the new bb

- {server.subdomain}.{state.baseDomain} + {server.serverUrl.replace(/^https?:\/\//u, "")} {" "} is reserved for it.

@@ -1067,12 +1066,7 @@ function AccountDashboard({ state }: { state: ServerState }) { {state.servers.map((s: ServerSummary) => ( - + ))}
@@ -1115,10 +1109,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..d6f5f74de5 100644 --- a/apps/web/src/server/api.test.ts +++ b/apps/web/src/server/api.test.ts @@ -26,8 +26,10 @@ import { getAccountState, redeemConnectCode, redeemMachineCode, + resolveServerUrlTemplate, revokeMachineForServerCredential, revokeMachine, + serverUrlForLabel, } from "./api.js"; import { sha256Hex } from "./tokens.js"; @@ -55,6 +57,7 @@ beforeEach(() => { db, baseDomain: "getbb.app", appUrl: "https://getbb.app", + serverUrlTemplate: "https://{label}.getbb.app", closeTunnel, }; }); @@ -78,6 +81,37 @@ function seedUser(id: string, githubLogin?: string): void { .run(); } +describe("server URL template boundary", () => { + it("defaults production and accepts a per-label loopback port", () => { + expect(resolveServerUrlTemplate(undefined, "getbb.app")).toBe( + "https://{label}.getbb.app", + ); + const local = resolveServerUrlTemplate( + "http://{label}.localhost:8791", + "localhost", + ); + expect(local).toBe("http://{label}.localhost:8791"); + expect(serverUrlForLabel("sawyer", local)).toBe( + "http://sawyer.localhost:8791", + ); + }); + + it("rejects fixed, foreign, and non-origin templates", () => { + expect(() => + resolveServerUrlTemplate("http://127.0.0.1:8791", "localhost"), + ).toThrow("must contain {label} exactly once"); + expect(() => + resolveServerUrlTemplate("http://{label}.example.com:8791", "localhost"), + ).toThrow("form {label}.BASE_DOMAIN"); + expect(() => + resolveServerUrlTemplate( + "http://{label}.localhost:8791/path", + "localhost", + ), + ).toThrow("form {label}.BASE_DOMAIN"); + }); +}); + describe("claimHandle", () => { it("creates the profile and the primary server (subdomain = handle)", async () => { seedUser("u1"); @@ -197,6 +231,7 @@ describe("createConnectCode (per-server minting + reuse)", () => { serverId: desktop.server.id, }); if ("error" in r) throw new Error(r.error); + expect(r.baseUrl).toBe("https://getbb.app"); expect(r.serverUrl).toBe("https://sawyer-desktop.getbb.app"); expect(r.serverId).toBe(desktop.server.id); @@ -267,6 +302,7 @@ describe("redeemConnectCode (multi-server routing label)", () => { expect(result.handle).toBe("sawyer-desktop"); expect(result.serverId).toBe(desktop.server.id); // (b) tunnelUrl is keyed by that subdomain. + expect(result.serverUrl).toBe("https://sawyer-desktop.getbb.app"); expect(result.tunnelUrl).toBe("wss://sawyer-desktop.getbb.app/__tunnel"); expect(result.credential.startsWith("bbcred_")).toBe(true); @@ -306,8 +342,35 @@ describe("redeemConnectCode (multi-server routing label)", () => { // Primary server: subdomain === account handle — byte-identical pre-fix behavior. expect(result.handle).toBe("sawyer"); + expect(result.serverUrl).toBe("https://sawyer.getbb.app"); expect(result.tunnelUrl).toBe("wss://sawyer.getbb.app/__tunnel"); }); + + it("returns the configured per-label Connect gate URL as authoritative", async () => { + 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, + serverUrlTemplate: "http://{label}.localhost:8791", + }, + minted.code, + ); + if ("error" in result) throw new Error(result.error); + + expect(result.serverUrl).toBe("http://sawyer.localhost:8791"); + expect(result.tunnelUrl).toBe("ws://sawyer.localhost:8791/__tunnel"); + }); }); describe("disconnectServer (server-scoped)", () => { diff --git a/apps/web/src/server/api.ts b/apps/web/src/server/api.ts index abdcd3ea6d..3d73d0b2d2 100644 --- a/apps/web/src/server/api.ts +++ b/apps/web/src/server/api.ts @@ -28,14 +28,53 @@ export interface Deps { db: ConnectDb; baseDomain: string; appUrl: string; + /** Authoritative gate origin template; `{label}` is the routing label. */ + serverUrlTemplate: string; closeTunnel?: (routingKey: string) => Promise; } +export function resolveServerUrlTemplate( + value: string | undefined, + baseDomain: string, +): string { + const configured = value?.trim(); + if (!configured) return `https://{label}.${baseDomain}`; + if (configured.split("{label}").length !== 2) { + throw new Error( + "CONNECT_SERVER_URL_TEMPLATE must contain {label} exactly once", + ); + } + const probe = "bb-label-probe"; + const url = new URL(configured.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 in the form {label}.BASE_DOMAIN", + ); + } + return `${url.protocol}//{label}.${baseDomain}${url.port ? `:${url.port}` : ""}`; +} + +export 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"); @@ -80,6 +119,8 @@ export interface AccountState { servers: ServerSummary[]; appUrl: string; baseDomain: string; + /** Same template used for previews and the authoritative pairing response. */ + 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 +144,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 +162,7 @@ function toServerSummary( lastSeenAt: lastSeenMs, version: srv.version, createdAt: srv.createdAt.getTime(), - serverUrl: `https://${srv.subdomain}.${baseDomain}`, + serverUrl: serverUrlForLabel(srv.subdomain, serverUrlTemplate), }; } @@ -182,6 +223,7 @@ export async function getAccountState( const base = { appUrl: deps.appUrl, baseDomain, + serverUrlTemplate: deps.serverUrlTemplate, githubLogin: userRow?.githubLogin ?? null, maxServers: MAX_SERVERS_PER_ACCOUNT, }; @@ -204,7 +246,8 @@ export async function getAccountState( id: row.id, name: row.name, subdomain: row.subdomain, - online: lastSeenMs != null && now - lastSeenMs < SERVER_OFFLINE_AFTER_MS, + online: + lastSeenMs != null && now - lastSeenMs < SERVER_OFFLINE_AFTER_MS, lastSeenAt: lastSeenMs, createdAt: row.createdAt.getTime(), }; @@ -222,7 +265,9 @@ export async function getAccountState( .all(); const servers = serverRows - .map((srv) => toServerSummary(srv, prof.handle, baseDomain, now)) + .map((srv) => + toServerSummary(srv, prof.handle, deps.serverUrlTemplate, now), + ) .sort((a, b) => a.isPrimary !== b.isPrimary ? a.isPrimary @@ -385,7 +430,7 @@ export async function createServer( userId: string, rawLabel: string, ): Promise<{ ok: true; server: ServerSummary } | { error: CreateServerError }> { - const { db, baseDomain } = deps; + const { db } = deps; const prof = await db .select() .from(profile) @@ -436,13 +481,20 @@ export async function createServer( } return { ok: true, - server: toServerSummary(created, prof.handle, baseDomain, Date.now()), + server: toServerSummary( + created, + prof.handle, + deps.serverUrlTemplate, + Date.now(), + ), }; } export interface IssuedCode { code: string; expiresInMs: number; + /** Account/redeem origin; distinct from serverUrl in local development. */ + baseUrl: string; serverUrl: string; serverId: string; } @@ -457,10 +509,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) { @@ -482,6 +534,7 @@ export async function createConnectCode( return { code: valid.code, expiresInMs: valid.expiresAt.getTime() - now, + baseUrl: deps.appUrl, serverUrl, serverId: srv.id, }; @@ -504,6 +557,7 @@ export async function createConnectCode( return { code, expiresInMs: CONNECT_CODE_TTL_MS, + baseUrl: deps.appUrl, serverUrl, serverId: srv.id, }; @@ -517,7 +571,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 +613,7 @@ export async function createMachineCode( return { code, expiresInMs: CONNECT_CODE_TTL_MS, - serverUrl: `https://${srv.subdomain}.${baseDomain}`, + serverUrl: serverUrlForLabel(srv.subdomain, serverUrlTemplate), }; } @@ -711,24 +765,27 @@ function rowsChanged(result: unknown): number { * `handle` is the redeemed server's routing label (its subdomain). For the * primary server this equals the account handle, so primary pairing is * byte-identical to the pre-multi-server behavior. The tunnel client uses - * this field to build serverUrl / share URLs — it must not be the account's - * primary handle when a non-primary server was paired. + * this field to build share URLs — it must not be the account's primary handle + * when a non-primary server was paired. `serverUrl` is the authoritative gate + * origin; production and local development both derive it from the label via + * the boundary-validated server URL template. * * Accepts `Deps` (D1 in the worker via `depsFromEnv`, better-sqlite3 in tests). */ export async function redeemConnectCode( - deps: Pick, + deps: Pick, code: string, ): Promise< | { credential: string; serverId: string; - handle: string | null; - tunnelUrl: string | null; + handle: string; + serverUrl: string; + tunnelUrl: string; } | { 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 +822,17 @@ 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(); + if (!srv) return { error: "invalid-code", status: 404 }; // Routing label of the redeemed server (not necessarily the account handle). - const handle = srv?.subdomain ?? prof?.handle ?? null; + const handle = srv.subdomain; + const serverUrl = serverUrlForLabel(handle, serverUrlTemplate); return { credential, serverId: row.serverId, handle, + serverUrl, // 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.replace(/^http/u, "ws")}/__tunnel`, }; } @@ -786,7 +841,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 +852,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 +921,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-runtime.test.ts b/apps/web/src/server/auth-runtime.test.ts new file mode 100644 index 0000000000..f5a708286a --- /dev/null +++ b/apps/web/src/server/auth-runtime.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + crossSubdomainCookieConfig, + isLoopbackHostname, + resolveDevAuthUserId, +} from "./auth-runtime.js"; + +describe("local web authentication", () => { + it.each(["127.0.0.1", "127.1.2.3", "localhost", "[::1]"])( + "recognizes the loopback hostname %s", + (hostname) => { + expect(isLoopbackHostname(hostname)).toBe(true); + }, + ); + + it.each(["getbb.app", "127.0.0.256", "128.0.0.1", "localhost.example"])( + "rejects the non-loopback hostname %s", + (hostname) => { + expect(isLoopbackHostname(hostname)).toBe(false); + }, + ); + + it("returns the seeded user for an HTTP loopback app", () => { + expect( + resolveDevAuthUserId({ + APP_URL: "http://127.0.0.1:8792", + DEV_AUTH_USER_ID: " usr_cloud_dev ", + }), + ).toBe("usr_cloud_dev"); + }); + + it("uses Better Auth when no seeded user is configured", () => { + expect( + resolveDevAuthUserId({ + APP_URL: "https://getbb.app", + }), + ).toBeNull(); + }); + + it.each([ + "https://getbb.app", + "https://127.0.0.1:8792", + "http://dev.getbb.app:8792", + ])("fails closed when seeded auth is configured for %s", (APP_URL) => { + expect(() => + resolveDevAuthUserId({ APP_URL, DEV_AUTH_USER_ID: "usr_cloud_dev" }), + ).toThrow(/only allowed/u); + }); +}); + +describe("Better Auth cookie scope", () => { + it("shares production cookies with Connect subdomains", () => { + expect( + crossSubdomainCookieConfig({ + APP_URL: "https://getbb.app", + BASE_DOMAIN: "getbb.app", + }), + ).toEqual({ enabled: true, domain: ".getbb.app" }); + }); + + it("shares staging cookies with its Connect subdomains", () => { + expect( + crossSubdomainCookieConfig({ + APP_URL: "https://auth.vibecodethis.site", + BASE_DOMAIN: "vibecodethis.site", + }), + ).toEqual({ enabled: true, domain: ".vibecodethis.site" }); + }); + + it("uses a host-only cookie for the local web worker", () => { + expect( + crossSubdomainCookieConfig({ + APP_URL: "http://127.0.0.1:8792", + BASE_DOMAIN: "getbb.app", + }), + ).toEqual({ enabled: false }); + }); +}); diff --git a/apps/web/src/server/auth-runtime.ts b/apps/web/src/server/auth-runtime.ts new file mode 100644 index 0000000000..9d6fcfc917 --- /dev/null +++ b/apps/web/src/server/auth-runtime.ts @@ -0,0 +1,80 @@ +import type { Env } from "./env.js"; + +const LOOPBACK_IPV4_PREFIX = "127."; + +function normalizeHostname(hostname: string): string { + const normalized = hostname.trim().toLowerCase(); + return normalized.startsWith("[") && normalized.endsWith("]") + ? normalized.slice(1, -1) + : normalized; +} + +export function isLoopbackHostname(hostname: string): boolean { + const normalized = normalizeHostname(hostname); + if (normalized === "localhost" || normalized === "::1") return true; + if (!normalized.startsWith(LOOPBACK_IPV4_PREFIX)) return false; + + const octets = normalized.split("."); + return ( + octets.length === 4 && + octets.every((octet) => { + if (!/^\d{1,3}$/u.test(octet)) return false; + const value = Number(octet); + return value >= 0 && value <= 255; + }) + ); +} + +function appUrl(env: Pick): URL { + try { + return new URL(env.APP_URL); + } catch { + throw new Error("APP_URL must be an absolute URL"); + } +} + +/** + * The launcher may opt into a seeded account, but only for a loopback web app. + * Throwing on a non-loopback origin makes an accidental production binding + * fail closed instead of silently bypassing Better Auth. + */ +export function resolveDevAuthUserId( + env: Pick, +): string | null { + if (env.DEV_AUTH_USER_ID === undefined) return null; + + const userId = env.DEV_AUTH_USER_ID.trim(); + if (userId.length === 0) { + throw new Error("DEV_AUTH_USER_ID must be a non-empty user id"); + } + + const url = appUrl(env); + if (url.protocol !== "http:" || !isLoopbackHostname(url.hostname)) { + throw new Error( + "DEV_AUTH_USER_ID is only allowed when APP_URL is an HTTP loopback origin", + ); + } + return userId; +} + +/** + * Production/staging share the Better Auth session with label subdomains. + * Loopback and preview origins instead receive a host-only cookie; a Domain + * attribute for `.getbb.app` would be rejected by a browser on 127.0.0.1. + */ +export function crossSubdomainCookieConfig( + env: Pick, +): { enabled: false } | { enabled: true; domain: string } { + const url = appUrl(env); + const hostname = normalizeHostname(url.hostname); + const baseDomain = normalizeHostname(env.BASE_DOMAIN).replace(/^\./u, ""); + if (baseDomain.length === 0) { + throw new Error("BASE_DOMAIN must be a non-empty hostname"); + } + + const belongsToBaseDomain = + hostname === baseDomain || hostname.endsWith(`.${baseDomain}`); + return belongsToBaseDomain + ? { enabled: true, domain: `.${baseDomain}` } + : { enabled: false }; +} diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 0b417223ff..7841401998 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -3,13 +3,16 @@ 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 { crossSubdomainCookieConfig } from "./auth-runtime.js"; export type Auth = ReturnType; /** - * better-auth bound to the staging D1 via drizzle. GitHub is the only provider. - * Cookies are scoped to `.${BASE_DOMAIN}` so the tunnel gate on - * `.${BASE_DOMAIN}` can validate the same session. + * better-auth bound to the account D1 via drizzle. GitHub is the only provider. + * Deployed cookies are scoped to `.${BASE_DOMAIN}` so the tunnel gate on + * `.${BASE_DOMAIN}` can validate the same session. Loopback development + * uses a host-only cookie because browsers reject a getbb.app Domain attribute + * on 127.0.0.1. */ export function createAuth(env: Env) { const db = drizzle(env.DB); @@ -41,7 +44,7 @@ export function createAuth(env: Env) { }, }, advanced: { - crossSubDomainCookies: { enabled: true, domain: `.${env.BASE_DOMAIN}` }, + crossSubDomainCookies: crossSubdomainCookieConfig(env), }, }); } diff --git a/apps/web/src/server/current-user.server.ts b/apps/web/src/server/current-user.server.ts index ad1bf45da3..f903802c4e 100644 --- a/apps/web/src/server/current-user.server.ts +++ b/apps/web/src/server/current-user.server.ts @@ -1,14 +1,19 @@ import { getRequest } from "@tanstack/react-start/server"; import { createAuth } from "./auth.js"; import { getEnv } from "./env.js"; +import { resolveDevAuthUserId } from "./auth-runtime.js"; // `.server.ts`: server-only. Never import from client code — only from server // functions / route handlers. Holds the request-bound session lookup. /** The authenticated user id for the current request, or null. */ export async function getSessionUserId(): Promise { + const env = getEnv(); + const devUserId = resolveDevAuthUserId(env); + if (devUserId !== null) return devUserId; + const request = getRequest(); - const auth = createAuth(getEnv()); + const auth = createAuth(env); const session = await auth.api.getSession({ headers: request.headers }); return session?.user?.id ?? null; } diff --git a/apps/web/src/server/env.ts b/apps/web/src/server/env.ts index ed3d8d8cf4..19319b30f2 100644 --- a/apps/web/src/server/env.ts +++ b/apps/web/src/server/env.ts @@ -5,6 +5,17 @@ export interface Env { TUNNEL_DO: DurableObjectNamespace; BASE_DOMAIN: string; APP_URL: string; + /** + * Loopback-only account override used by the local Cloud launcher. Omitted + * everywhere that should authenticate through Better Auth. + */ + DEV_AUTH_USER_ID?: string; + /** + * Optional authoritative Connect gate URL template. `{label}` is replaced + * with the claimed routing label. Production derives the equivalent template + * from BASE_DOMAIN; local development supplies its worktree-specific port. + */ + CONNECT_SERVER_URL_TEMPLATE?: string; GITHUB_CLIENT_ID: string; GITHUB_CLIENT_SECRET: string; BETTER_AUTH_SECRET: string; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 984700df00..c08757d28a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -5,6 +5,34 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +const cloudDevStatePath = process.env.BB_CLOUD_DEV_STATE_PATH?.trim(); +const cloudDevAppUrl = process.env.BB_CLOUD_DEV_APP_URL?.trim(); +const cloudDevBaseDomain = process.env.BB_CLOUD_DEV_BASE_DOMAIN?.trim(); +const cloudDevConnectServerUrlTemplate = + process.env.BB_CLOUD_DEV_CONNECT_SERVER_URL_TEMPLATE?.trim(); +const cloudDevAuthUserId = process.env.DEV_AUTH_USER_ID?.trim(); + +const cloudDevConfig = + cloudDevStatePath && + cloudDevAppUrl && + cloudDevBaseDomain && + cloudDevConnectServerUrlTemplate + ? { + persistState: { path: cloudDevStatePath }, + config: (config: { vars?: Record }) => ({ + vars: { + ...config.vars, + APP_URL: cloudDevAppUrl, + BASE_DOMAIN: cloudDevBaseDomain, + CONNECT_SERVER_URL_TEMPLATE: cloudDevConnectServerUrlTemplate, + ...(cloudDevAuthUserId + ? { DEV_AUTH_USER_ID: cloudDevAuthUserId } + : {}), + }, + }), + } + : {}; + export default defineConfig({ resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, @@ -15,7 +43,10 @@ export default defineConfig({ allowedHosts: [".ts.net"], }, plugins: [ - cloudflare({ viteEnvironment: { name: "ssr" } }), + cloudflare({ + viteEnvironment: { name: "ssr" }, + ...cloudDevConfig, + }), tailwindcss(), tanstackStart(), viteReact(), diff --git a/docs/configuration.md b/docs/configuration.md index 5c4b340c62..8beefdf210 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -94,14 +94,16 @@ signal it, so a stale file left by a crash cannot stop an unrelated process. ## Common Keys -| Key | Command | When to set | Used for | -| ------------------ | --------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `BB_APP_URL` | `bb-app config` | Optional for remote use | Human-facing app URL used for generated links and allowed browser origins. Leave empty for local-only use. | -| `BB_INFERENCE` | `bb-app config` | Optional | Server-side helper model in `provider/model` format. Defaults to `codex/gpt-5.6-luna`; the Codex helper route uses no reasoning. | -| `BB_TRANSCRIPTION` | `bb-app config` | Optional | Voice transcription model in `provider/model` format. Defaults to `codex/gpt-transcribe`. | -| `BB_SERVER_URL` | `bb-app config` | Remote CLI/host use | Server URL for standalone `bb` CLI and `host-daemon` commands on the current machine. The CLI defaults to `http://127.0.0.1:38886` when unset. | -| `BB_LOG_LEVEL` | `bb-app config` | Debugging | Log level for the next bb start: `trace`, `debug`, `info`, `warn`, `error`, or `fatal`. | -| `OPENAI_API_KEY` | `bb-app env` | OpenAI opt-in routes | Required only when selecting explicit OpenAI provider routes such as `openai/gpt-4o-mini` or `openai/gpt-transcribe`. | +| Key | Command | When to set | Used for | +| ------------------------- | --------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `BB_APP_URL` | `bb-app config` | Optional for remote use | Human-facing app URL used for generated links and allowed browser origins. Leave empty for local-only use. | +| `BB_INFERENCE` | `bb-app config` | Optional | Server-side helper model in `provider/model` format. Defaults to `codex/gpt-5.6-luna`; the Codex helper route uses no reasoning. | +| `BB_TRANSCRIPTION` | `bb-app config` | Optional | Voice transcription model in `provider/model` format. Defaults to `codex/gpt-transcribe`. | +| `BB_SERVER_URL` | `bb-app config` | Remote CLI/host use | Server URL for standalone `bb` CLI and `host-daemon` commands on the current machine. The CLI defaults to `http://127.0.0.1:38886` when unset. | +| `BB_CONNECT_BASE_URL` | `bb-app env` | Alternate Cloud | HTTP(S) origin used for Cloud account links and pairing-code redemption. Leave unset for `https://getbb.app`. | +| `BB_CONNECT_LOOPBACK_URL` | `bb-app env` | Source QA only | Loopback HTTP origin served by the bare Cloud handle. `pnpm cloud:dev` manages this automatically. | +| `BB_LOG_LEVEL` | `bb-app config` | Debugging | Log level for the next bb start: `trace`, `debug`, `info`, `warn`, `error`, or `fatal`. | +| `OPENAI_API_KEY` | `bb-app env` | OpenAI opt-in routes | Required only when selecting explicit OpenAI provider routes such as `openai/gpt-4o-mini` or `openai/gpt-transcribe`. | By default, helper inference and voice transcription use Codex credentials from the host daemon. Run `codex login` on the host for the default path. Set @@ -463,6 +465,21 @@ The tunnel client lives in `plugins/connect/`; the CLI command is proxied to the plugin, and Settings → Connect drives the plugin's rpc (including shared ports). +`BB_CONNECT_BASE_URL` changes the account and pairing-code redemption origin. +It must be an HTTP(S) origin with no path, query, credentials, or fragment. The +redemption response supplies the authoritative tunnel-gate URL, so account and +gate services may use different origins. Leave the setting unset in production +to use `https://getbb.app`; source development may put it in +`.env.development.local`. The `pnpm cloud:dev` launcher applies its local value +to the current checkout's dev server while running and restores the previous +managed environment value when it exits. + +`BB_CONNECT_LOOPBACK_URL` is a source-development escape hatch for selecting +the loopback HTTP origin served by the bare Cloud handle. It accepts only an +HTTP origin on `127.0.0.1`, `localhost`, or `::1`. Leave it unset in packaged +and production use. `pnpm cloud:dev` manages it automatically so the handle +serves the current worktree's Vite app rather than its API-only server port. + ## Experiments Experimental surfaces are off by default and can be changed in Settings → diff --git a/docs/debugging-and-qa.md b/docs/debugging-and-qa.md index 095bf3dc2a..bd90add77b 100644 --- a/docs/debugging-and-qa.md +++ b/docs/debugging-and-qa.md @@ -30,3 +30,73 @@ 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 bb Cloud + +Run the Cloud account service and tunnel/AI gate locally with one command: + +```bash +pnpm cloud:dev +``` + +Before the first run, copy `apps/connect/.dev.vars.example` to the ignored +`apps/connect/.dev.vars`, add an OpenAI API key, and generate the local auth +secret with `openssl rand -hex 32`. The launcher applies the Connect migrations +to an ignored, checkout-local D1 under `.wrangler/cloud-dev`, starts `apps/web` +and `apps/connect` on deterministic, worktree-specific ports against that same +database, creates only a local authenticated user identity, and configures the +current checkout's dev server to use the local account worker. Open the +dashboard URL printed by the launcher, choose a handle, create the first bb, +and generate its pairing code through the same UI as production. A handle such +as `michael` gets the real worktree-local gate URL printed by the UI, +`http://michael.localhost:`; local development never displays or +dials a `getbb.app` address. It skips GitHub authentication but does not seed a +handle, server, or pairing code. It never reads or writes remote Cloudflare +resources. + +Keep ordinary `pnpm dev` running in another terminal. The launcher applies +`BB_CONNECT_BASE_URL` and `BB_CONNECT_LOOPBACK_URL` through the dev server's +managed environment reload, so the settings flow needs no URL field and no +server restart. The latter targets the current worktree's Vite app port, which +means the bare local handle serves the same UI as `pnpm dev`. The launcher +restores both previous values when stopped. After generating a code in the +Cloud dashboard, open the printed Settings → Cloud URL and paste it. Use the +printed commands to enable the `cloudAi` experiment and AI preference before +testing thread-title inference, commit-message inference, or voice +transcription. Press Ctrl-C in the Cloud terminal to stop both Workers. + +The dashboard's terminal disclosure is also self-contained: its `bb connect` +command includes both the per-label local gate (`--server`) and the separate +account/redeem worker (`--base-url`). It does not depend on production's +single-origin deployment topology. + +The printed setup unsets `BB_CLI` after selecting the dev server. Agent shells +can inherit that variable from another running bb installation; leaving it set +would make `pnpm bb:dev` re-exec that installation's CLI instead of the CLI in +this checkout. + +The defaults use the same checkout hash as `pnpm dev`, in separate port ranges, +so multiple worktrees can run concurrently without manual configuration. Flags +remain available for a one-off override: + +```bash +pnpm cloud:dev -- --connect-port 8891 --web-port 8892 +``` + +To exercise the real GitHub OAuth flow instead of seeded authentication, create +a separate local GitHub OAuth App. Configure its homepage as +`http://127.0.0.1` and its authorization callback as +`http://127.0.0.1/api/auth/callback/github`. GitHub permits the redirect URI for +a loopback callback to select the actual listening port, so the same local OAuth +App works across worktrees. Copy `apps/web/.dev.vars.example` to the ignored +`apps/web/.dev.vars`, add that app's client id and secret, then run: + +```bash +pnpm cloud:dev -- --github-auth +``` + +The launcher does not enable seeded authentication or mint a seeded pairing +code in this mode. Continue with GitHub in `apps/web`, then claim a handle and +create/pair a bb from the dashboard. The local Better Auth cookie is host-only +and uses its HTTP name; deployed staging and production retain the shared, +secure domain cookie. diff --git a/package.json b/package.json index 7b24c19717..4925c02370 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", diff --git a/packages/config/src/runtime.ts b/packages/config/src/runtime.ts index f0481cfdb0..cbbb368395 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; + cloudConnectPort: number; + cloudWebPort: number; hostDaemonPort: number; serverPort: number; } @@ -88,6 +90,10 @@ 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_CONNECT_PORT_BASE = 35_000; +// The Connect range skips the packaged app's 38886/38887 pair, ending at +// 43001. Start web immediately after it so all five dev ranges stay disjoint. +const DEV_CLOUD_WEB_PORT_BASE = 43_002; const DEV_PROCESS_STRIPPED_ENV_KEYS: readonly string[] = [ "BB_ENVIRONMENT_ID", "BB_THREAD_ID", @@ -134,10 +140,22 @@ function resolvePortOffset(repoRootPath: string): number { return Number.parseInt(hash.slice(0, 8), 16) % DEV_PORT_BUCKETS; } +function skipPackagedAppPorts(port: number): number { + let availablePort = port; + for (const reservedPort of [BB_PROD_SERVER_PORT, BB_PROD_HOST_DAEMON_PORT]) { + if (availablePort >= reservedPort) availablePort += 1; + } + return availablePort; +} + function resolvePorts(repoRootPath: string): DevPortSet { const offset = resolvePortOffset(repoRootPath); return { appPort: DEV_APP_PORT_BASE + offset, + cloudConnectPort: skipPackagedAppPorts( + DEV_CLOUD_CONNECT_PORT_BASE + offset, + ), + cloudWebPort: DEV_CLOUD_WEB_PORT_BASE + offset, hostDaemonPort: DEV_HOST_DAEMON_PORT_BASE + offset, serverPort: DEV_SERVER_PORT_BASE + offset, }; diff --git a/packages/connect-db/src/constants.ts b/packages/connect-db/src/constants.ts index 7e8c168844..16e9b4dbd7 100644 --- a/packages/connect-db/src/constants.ts +++ b/packages/connect-db/src/constants.ts @@ -127,8 +127,8 @@ export interface VisitorHost { /** * Resolve a visitor host to its handle and optional share target. * - * - `.` → `{ handle, target: null }` - * - `--.` → `{ handle, target: port }` when port is a + * - `.[:listener-port]` → `{ handle, target: null }` + * - `--.[:listener-port]` → `{ handle, target: port }` when port is a * valid decimal 1–65535 with no leading zeros * - apex, multi-level labels, foreign domains, or invalid share labels → null * @@ -136,10 +136,29 @@ export interface VisitorHost { * (prefix = handle, suffix = target). An invalid target makes the whole * host unroutable (null), not a bare-handle fallback. */ -export function parseVisitorHost(host: string, baseDomain: string): VisitorHost | null { - const suffix = `.${baseDomain}`; - if (!host.endsWith(suffix)) return null; - const label = host.slice(0, -suffix.length); +export function parseVisitorHost( + host: string, + baseDomain: string, +): VisitorHost | null { + let hostname: string; + try { + const parsed = new URL(`http://${host}`); + if ( + parsed.username !== "" || + parsed.password !== "" || + parsed.pathname !== "/" || + parsed.search !== "" || + parsed.hash !== "" + ) { + return null; + } + hostname = parsed.hostname.toLowerCase(); + } catch { + return null; + } + const suffix = `.${baseDomain.toLowerCase()}`; + if (!hostname.endsWith(suffix)) return null; + const label = hostname.slice(0, -suffix.length); if (!label || label.includes(".")) return null; const sep = label.indexOf("--"); diff --git a/packages/connect-db/test/schema.test.ts b/packages/connect-db/test/schema.test.ts index 0c524c3eaf..fc47626767 100644 --- a/packages/connect-db/test/schema.test.ts +++ b/packages/connect-db/test/schema.test.ts @@ -506,6 +506,10 @@ describe("parseVisitorHost", () => { handle: "sawyer", target: null, }); + expect(parseVisitorHost("sawyer.localhost:8791", "localhost")).toEqual({ + handle: "sawyer", + target: null, + }); }); it("extracts handle--port share hosts", () => { @@ -517,6 +521,9 @@ describe("parseVisitorHost", () => { handle: "sawyer", target: "5173", }); + expect( + parseVisitorHost("sawyer--5173.localhost:8791", "localhost"), + ).toEqual({ handle: "sawyer", target: "5173" }); }); it("rejects invalid share targets as unroutable", () => { @@ -533,6 +540,9 @@ describe("parseVisitorHost", () => { expect(parseVisitorHost("a.b.getbb.app", "getbb.app")).toBeNull(); expect(parseVisitorHost("evil.com", "getbb.app")).toBeNull(); expect(parseVisitorHost("getbb.app.evil.com", "getbb.app")).toBeNull(); + expect( + parseVisitorHost("sawyer.localhost:not-a-port", "localhost"), + ).toBeNull(); }); }); diff --git a/packages/scripts/test/dev-instance-expectations.ts b/packages/scripts/test/dev-instance-expectations.ts index 3bb73de030..b6726ecc97 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; + cloudConnectPort: number; + cloudWebPort: number; hostDaemonPort: number; serverPort: number; } @@ -26,10 +28,20 @@ function expectedPortOffset(repoRoot: string): number { ); } +function expectedCloudConnectPort(offset: number): number { + let port = 35_000 + offset; + for (const reservedPort of [38_886, 38_887]) { + if (port >= reservedPort) port += 1; + } + return port; +} + export function expectedDevPorts(repoRoot: string): ExpectedDevPortSet { const offset = expectedPortOffset(repoRoot); return { appPort: 11_000 + offset, + cloudConnectPort: expectedCloudConnectPort(offset), + cloudWebPort: 43_002 + 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..82f5c5b7e4 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); diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index ef86736b4b..8d4874d064 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 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`.\n `BB_CONNECT_BASE_URL` overrides the account and code-redemption origin for\n alternate deployments and local QA; leave it unset for getbb.app. The\n service response supplies the authoritative tunnel-gate URL, which may be a\n different origin.\n `BB_CONNECT_LOOPBACK_URL` is a source-QA-only loopback HTTP origin served by\n the bare handle URL. Leave it unset outside source development;\n `pnpm cloud:dev` manages it automatically.", "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..abce80c05d 100644 --- a/packages/templates/src/templates/bb-guide-environments.md +++ b/packages/templates/src/templates/bb-guide-environments.md @@ -159,3 +159,10 @@ Remote access (bb connect): shows the URL, QR code, and shared ports). Disabling the plugin (`bb plugin disable connect`) cuts off all remote access; re-enable with `bb plugin enable connect`. + `BB_CONNECT_BASE_URL` overrides the account and code-redemption origin for + alternate deployments and local QA; leave it unset for getbb.app. The + service response supplies the authoritative tunnel-gate URL, which may be a + different origin. + `BB_CONNECT_LOOPBACK_URL` is a source-QA-only loopback HTTP origin served by + the bare handle URL. Leave it unset outside source development; + `pnpm cloud:dev` manages it automatically. diff --git a/plugins/connect/src/cli.ts b/plugins/connect/src/cli.ts index 93a4b1b1a6..e230be28ca 100644 --- a/plugins/connect/src/cli.ts +++ b/plugins/connect/src/cli.ts @@ -78,6 +78,7 @@ function helpText(): string { " bb connect servers List every bb on this account (from getbb.app)", "", "The server holds the tunnel; it stays up while bb is running.", + "Set BB_CONNECT_BASE_URL to an HTTP(S) origin only when using another Cloud account endpoint.", ].join("\n"); } diff --git a/plugins/connect/src/connect.test.ts b/plugins/connect/src/connect.test.ts index 50a2f35e59..dee5da523a 100644 --- a/plugins/connect/src/connect.test.ts +++ b/plugins/connect/src/connect.test.ts @@ -26,6 +26,12 @@ import plugin from "./server.js"; import { ConnectTunnel } from "./tunnel.js"; import type { ConnectStatus } from "./types.js"; import { ShareHostResolver } from "./hosts.js"; +import { + CONNECT_BASE_URL_ENV_NAME, + CONNECT_LOOPBACK_URL_ENV_NAME, + resolveConnectBaseUrlOverride, + resolveConnectLoopbackUrlOverride, +} from "./redeem.js"; const SERVER_HOST_ID = "host-server"; const SERVER_HOST_NAME = "Server"; @@ -92,6 +98,60 @@ describe("serverUrlForHandle", () => { }); }); +describe("resolveConnectBaseUrlOverride", () => { + it("accepts an HTTP(S) origin and normalizes its trailing slash", () => { + expect( + resolveConnectBaseUrlOverride({ + [CONNECT_BASE_URL_ENV_NAME]: " http://127.0.0.1:8792/ ", + }), + ).toBe("http://127.0.0.1:8792"); + expect(resolveConnectBaseUrlOverride({})).toBeNull(); + }); + + it("rejects non-origin and non-HTTP values", () => { + expect(() => + resolveConnectBaseUrlOverride({ + [CONNECT_BASE_URL_ENV_NAME]: "http://127.0.0.1:8792/path", + }), + ).toThrow("must be an HTTP(S) origin"); + expect(() => + resolveConnectBaseUrlOverride({ + [CONNECT_BASE_URL_ENV_NAME]: "file:///tmp/connect", + }), + ).toThrow("must be an HTTP(S) origin"); + }); +}); + +describe("resolveConnectLoopbackUrlOverride", () => { + it("accepts only a loopback HTTP origin", () => { + expect( + resolveConnectLoopbackUrlOverride({ + [CONNECT_LOOPBACK_URL_ENV_NAME]: " http://127.0.0.1:14577/ ", + }), + ).toBe("http://127.0.0.1:14577"); + expect( + resolveConnectLoopbackUrlOverride({ + [CONNECT_LOOPBACK_URL_ENV_NAME]: "http://[::1]:14577", + }), + ).toBe("http://[::1]:14577"); + expect(resolveConnectLoopbackUrlOverride({})).toBeNull(); + }); + + it("rejects remote, HTTPS, and path-bearing origins", () => { + for (const value of [ + "http://example.com:14577", + "https://127.0.0.1:14577", + "http://127.0.0.1:14577/app", + ]) { + expect(() => + resolveConnectLoopbackUrlOverride({ + [CONNECT_LOOPBACK_URL_ENV_NAME]: value, + }), + ).toThrow("must be a loopback HTTP origin"); + } + }); +}); + describe("headersForLoopbackRequest", () => { it("rewrites the paired connect origin to the loopback app origin only", () => { expect( @@ -1308,6 +1368,7 @@ describe("connect plugin", () => { host = undefined; } vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it("starts unpaired — a healthy state, not needs-configuration", async () => { @@ -1407,6 +1468,60 @@ describe("connect plugin", () => { expect(status.paired).toBe(true); }); + it("uses the configured base URL and Cloud's authoritative server URL", async () => { + vi.stubEnv(CONNECT_BASE_URL_ENV_NAME, "http://127.0.0.1:8792"); + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + credential: "bbcred_live", + handle: "localbb", + serverUrl: "http://127.0.0.1:8791", + }), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + const { harness } = await loadPlugin(); + + const status = (await harness.callRpc("pair", { + code: "ABCD", + })) as ConnectStatus; + + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:8792/api/connect/redeem", + expect.objectContaining({ method: "POST" }), + ); + expect(status.url).toBe("http://127.0.0.1:8791"); + expect(status.dashboardUrl).toBe("http://127.0.0.1:8792/dashboard"); + }); + + it("rejects an invalid authoritative server URL without persisting it", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + credential: "bbcred_live", + handle: "localbb", + serverUrl: "file:///tmp/not-a-gate", + }), + { status: 200 }, + ), + ), + ); + const { bb, harness } = await loadPlugin(); + + await expect( + harness.callRpc("pair", { + code: "ABCD", + baseUrl: "http://127.0.0.1:8792", + }), + ).rejects.toThrow("network"); + expect(await bb.storage.kv.get(CREDENTIAL_KV_KEY)).toBeUndefined(); + }); + it("pair stores a non-primary routing label from redeem (multi-server)", async () => { // Cloud returns the redeemed server's subdomain, not the account handle. const fetchMock = vi.fn( diff --git a/plugins/connect/src/redeem.ts b/plugins/connect/src/redeem.ts index acf2869a97..d229eac3d5 100644 --- a/plugins/connect/src/redeem.ts +++ b/plugins/connect/src/redeem.ts @@ -1,7 +1,71 @@ // Redeem a one-time connect code against the connect cloud for a durable // tunnel credential. Ported from the kernel's services/connect/redeem.ts. +import { z } from "zod"; export const DEFAULT_CONNECT_BASE_URL = "https://getbb.app"; +export const CONNECT_BASE_URL_ENV_NAME = "BB_CONNECT_BASE_URL"; +export const CONNECT_LOOPBACK_URL_ENV_NAME = "BB_CONNECT_LOOPBACK_URL"; + +/** Read the optional Cloud account/redeem origin at the point of use. */ +export function resolveConnectBaseUrlOverride( + env: NodeJS.ProcessEnv = process.env, +): string | null { + const configured = env[CONNECT_BASE_URL_ENV_NAME]?.trim(); + if (!configured) return null; + let url: URL; + try { + url = new URL(configured); + } catch { + throw new Error(`${CONNECT_BASE_URL_ENV_NAME} must be a valid URL`); + } + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username !== "" || + url.password !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" + ) { + throw new Error(`${CONNECT_BASE_URL_ENV_NAME} must be an HTTP(S) origin`); + } + return url.origin; +} + +/** + * Source-development override for the local origin served through the tunnel. + * It is intentionally loopback-only: allowing arbitrary origins would turn a + * pairing into an ambient HTTP relay to another machine. + */ +export function resolveConnectLoopbackUrlOverride( + env: NodeJS.ProcessEnv = process.env, +): string | null { + const configured = env[CONNECT_LOOPBACK_URL_ENV_NAME]?.trim(); + if (!configured) return null; + let url: URL; + try { + url = new URL(configured); + } catch { + throw new Error(`${CONNECT_LOOPBACK_URL_ENV_NAME} must be a valid URL`); + } + const isLoopback = + url.hostname === "127.0.0.1" || + url.hostname === "localhost" || + url.hostname === "[::1]"; + if ( + url.protocol !== "http:" || + !isLoopback || + url.username !== "" || + url.password !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" + ) { + throw new Error( + `${CONNECT_LOOPBACK_URL_ENV_NAME} must be a loopback HTTP origin`, + ); + } + return url.origin; +} export interface RedeemedCredential { credential: string; @@ -12,8 +76,43 @@ export interface RedeemedCredential { * the account's primary handle. */ handle: string; + /** + * Gate origin chosen by Cloud. Older deployed redeem endpoints omit it, in + * which case the client retains the legacy handle + base URL derivation. + */ + serverUrl?: string; } +const httpOriginSchema = z + .string() + .url() + .transform((value, context) => { + const url = new URL(value); + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username !== "" || + url.password !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" + ) { + context.addIssue({ + code: "custom", + message: "Expected an HTTP(S) origin", + }); + return z.NEVER; + } + return url.origin; + }); + +const redeemedCredentialSchema = z.object({ + credential: z.string().min(1), + handle: z.string().min(1), + serverUrl: httpOriginSchema.optional(), +}); + +const redeemErrorSchema = z.object({ error: z.string().optional() }); + /** * Typed pairing failure. `code` is the stable, UI-facing reason (mapped to * human copy by the panel); `message` keeps the raw wire detail for the CLI @@ -73,12 +172,23 @@ export async function redeemConnectCode(args: { body: JSON.stringify({ code: args.code }), }); if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { error?: string }; + const parsedError = redeemErrorSchema.safeParse( + await res.json().catch(() => ({})), + ); + const wireError = parsedError.success ? parsedError.data.error : undefined; + throw new ConnectPairError( + pairErrorCodeForRedeem(res.status, wireError), + `Redeem failed (${res.status})${wireError ? `: ${wireError}` : ""}`, + ); + } + const parsed = redeemedCredentialSchema.safeParse( + await res.json().catch(() => null), + ); + if (!parsed.success) { throw new ConnectPairError( - pairErrorCodeForRedeem(res.status, body.error), - `Redeem failed (${res.status})${body.error ? `: ${body.error}` : ""}`, + "network", + `Redeem returned an invalid response: ${parsed.error.message}`, ); } - const data = (await res.json()) as RedeemedCredential; - return { credential: data.credential, handle: data.handle }; + return parsed.data; } diff --git a/plugins/connect/src/server.ts b/plugins/connect/src/server.ts index 4d6907a100..701d9421e1 100644 --- a/plugins/connect/src/server.ts +++ b/plugins/connect/src/server.ts @@ -4,6 +4,10 @@ import { createKvCredentialStore } from "./credential.js"; import { connectRpcContract, createRpcHandlers } from "./rpc.js"; import { ShareRegistry } from "./shares.js"; import { ConnectTunnel } from "./tunnel.js"; +import { + resolveConnectBaseUrlOverride, + resolveConnectLoopbackUrlOverride, +} from "./redeem.js"; import { ShareHostResolver } from "./hosts.js"; import { CONNECT_REALTIME_CHANNEL, @@ -15,12 +19,14 @@ export default async function plugin(bb: BbPluginApi) { // Tunnel is assigned below; ShareRegistry reads the live credential via this. let tunnel!: ConnectTunnel; const hostResolver = new ShareHostResolver(() => bb.sdk); + const getLoopbackBaseUrl = () => + resolveConnectLoopbackUrlOverride() ?? bb.server.loopbackBaseUrl; const shares = new ShareRegistry({ kv: bb.storage.kv, hosts: bb.hosts, hostResolver, - getLoopbackBaseUrl: () => bb.server.loopbackBaseUrl, + getLoopbackBaseUrl, getCredential: () => tunnel.getCredential(), log: bb.log, onChange: () => { @@ -31,7 +37,8 @@ export default async function plugin(bb: BbPluginApi) { tunnel = new ConnectTunnel({ store, shares, - getLoopbackBaseUrl: () => bb.server.loopbackBaseUrl, + getLoopbackBaseUrl, + getConnectBaseUrl: () => resolveConnectBaseUrlOverride(), log: bb.log, onStatusChange: (status) => bb.realtime.publish(CONNECT_REALTIME_CHANNEL, status), diff --git a/plugins/connect/src/tunnel.ts b/plugins/connect/src/tunnel.ts index 60569fa502..bca0f8cd21 100644 --- a/plugins/connect/src/tunnel.ts +++ b/plugins/connect/src/tunnel.ts @@ -55,6 +55,8 @@ export interface ConnectTunnelOptions { * bind-gated; the tunnel only needs it once a socket opens). */ getLoopbackBaseUrl: () => string; + /** Optional Cloud account/redeem origin override, read lazily for live config reloads. */ + getConnectBaseUrl?: () => string | null; log: PluginLogger; /** Fired on every state/handle/error/shares/presence transition. */ onStatusChange?: (status: ConnectStatus) => void; @@ -117,7 +119,7 @@ export class ConnectTunnel { args.baseUrl ?? (args.serverUrl !== undefined ? deriveConnectBaseUrl(args.serverUrl) - : DEFAULT_CONNECT_BASE_URL); + : (this.options.getConnectBaseUrl?.() ?? DEFAULT_CONNECT_BASE_URL)); this.pairing = true; this.publish(); try { @@ -134,7 +136,9 @@ export class ConnectTunnel { throw pairError; } const serverUrl = ( - args.serverUrl ?? serverUrlForHandle(baseUrl, redeemed.handle) + args.serverUrl ?? + redeemed.serverUrl ?? + serverUrlForHandle(baseUrl, redeemed.handle) ).replace(/\/$/, ""); const credential: ConnectCredential = { serverUrl, @@ -245,12 +249,13 @@ export class ConnectTunnel { }; } - /** getbb.app dashboard URL, derived from the paired base (or the apex). */ + /** Cloud dashboard URL, preferring the configured account origin. */ private dashboardUrl(): string { const base = - this.credential !== null + this.options.getConnectBaseUrl?.() ?? + (this.credential !== null ? deriveConnectBaseUrl(this.credential.serverUrl) - : DEFAULT_CONNECT_BASE_URL; + : DEFAULT_CONNECT_BASE_URL); return `${base.replace(/\/$/, "")}/dashboard`; } diff --git a/scripts/bb-cloud-dev.mjs b/scripts/bb-cloud-dev.mjs new file mode 100644 index 0000000000..f3241f5354 --- /dev/null +++ b/scripts/bb-cloud-dev.mjs @@ -0,0 +1,621 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { randomBytes } from "node:crypto"; +import { + createServer as createHttpServer, + request as httpRequest, +} from "node:http"; +import path from "node:path"; +import process from "node:process"; +import { spawn, spawnSync } from "node:child_process"; +import { createServer as createNetServer } from "node:net"; +import { fileURLToPath } from "node:url"; +import { resolveCurrentDevInstanceConfig } from "../packages/config/src/runtime.ts"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, ".."); +const SEEDED_USER_ID = "usr_cloud_dev"; +const BASE_DOMAIN = "localhost"; +const STATE_DIR = path.join(REPO_ROOT, ".wrangler", "cloud-dev"); +const CONNECT_VARS_PATH = path.join(REPO_ROOT, "apps", "connect", ".dev.vars"); +const WEB_VARS_PATH = path.join(REPO_ROOT, "apps", "web", ".dev.vars"); +const DEV_INSTANCE = resolveCurrentDevInstanceConfig(REPO_ROOT); +const DEFAULT_CONNECT_PORT = DEV_INSTANCE.ports.cloudConnectPort; +const DEFAULT_WEB_PORT = DEV_INSTANCE.ports.cloudWebPort; +const DEV_ENV_PATH = path.join(DEV_INSTANCE.dataDir, "env.json"); +const CONNECT_BASE_URL_ENV_NAME = "BB_CONNECT_BASE_URL"; +const CONNECT_LOOPBACK_URL_ENV_NAME = "BB_CONNECT_LOOPBACK_URL"; +const BB_APP_SOURCE_BIN = path.join( + REPO_ROOT, + "packages", + "bb-app", + "src", + "bin", + "bb-app.ts", +); + +function fail(message) { + console.error(`bb Cloud dev: ${message}`); + process.exit(1); +} + +function parsePort(value, flag) { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + fail(`${flag} must be an integer between 1 and 65535`); + } + return port; +} + +function parseArgs(argv) { + let connectPort = DEFAULT_CONNECT_PORT; + let webPort = DEFAULT_WEB_PORT; + let githubAuth = false; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") continue; + if (arg === "--help" || arg === "-h") { + console.log(`Usage: pnpm cloud:dev [-- --connect-port --web-port --github-auth] + +Starts apps/connect and apps/web against one local D1 database. + +Options: + --connect-port Connect worker port (this worktree: ${DEFAULT_CONNECT_PORT}) + --web-port Cloud account web port (this worktree: ${DEFAULT_WEB_PORT}) + --github-auth Authenticate with a local GitHub OAuth App instead of + the seeded local developer account`); + process.exit(0); + } + if (arg === "--github-auth") { + githubAuth = true; + continue; + } + if (arg === "--connect-port" || arg === "--web-port") { + const value = argv[index + 1]; + if (value === undefined) fail(`${arg} requires a value`); + if (arg === "--connect-port") connectPort = parsePort(value, arg); + else webPort = parsePort(value, arg); + index += 1; + continue; + } + fail(`unknown option: ${arg}; run pnpm cloud:dev -- --help for usage`); + } + if (connectPort === webPort) fail("Connect and web ports must be different"); + return { connectPort, webPort, githubAuth }; +} + +async function assertPortAvailable(port, label) { + await new Promise((resolve, reject) => { + const server = createNetServer(); + server.once("error", (error) => reject(error)); + server.listen(port, "127.0.0.1", () => server.close(resolve)); + }).catch((error) => { + const detail = error instanceof Error ? error.message : String(error); + fail(`${label} port ${port} is unavailable (${detail})`); + }); +} + +async function findEphemeralLoopbackPort() { + return new Promise((resolve, reject) => { + const server = createNetServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + server.close(); + reject( + new Error("could not allocate the internal Connect worker port"), + ); + return; + } + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + }); +} + +function proxyHeaders(request, routingToken) { + let routingLabel = ""; + try { + const hostname = new URL(`http://${request.headers.host ?? ""}`).hostname; + const suffix = `.${BASE_DOMAIN}`; + if (hostname.endsWith(suffix)) { + const candidate = hostname.slice(0, -suffix.length); + if (candidate && !candidate.includes(".")) routingLabel = candidate; + } + } catch { + // The Worker returns its ordinary unknown-host response. + } + return { + ...request.headers, + "x-bb-cloud-dev-routing-label": routingLabel, + "x-bb-cloud-dev-token": routingToken, + }; +} + +function writeUpgradeHead(socket, response) { + const statusLine = `HTTP/1.1 ${response.statusCode ?? 500} ${response.statusMessage ?? ""}\r\n`; + const rawHeaders = []; + for (let index = 0; index < response.rawHeaders.length; index += 2) { + rawHeaders.push( + `${response.rawHeaders[index]}: ${response.rawHeaders[index + 1]}`, + ); + } + socket.write(`${statusLine}${rawHeaders.join("\r\n")}\r\n\r\n`); +} + +async function startConnectLoopbackProxy({ + publicPort, + workerPort, + routingToken, +}) { + const proxy = createHttpServer((request, response) => { + const upstream = httpRequest( + { + hostname: "127.0.0.1", + port: workerPort, + method: request.method, + path: request.url, + headers: proxyHeaders(request, routingToken), + }, + (upstreamResponse) => { + response.writeHead( + upstreamResponse.statusCode ?? 502, + upstreamResponse.headers, + ); + upstreamResponse.pipe(response); + }, + ); + upstream.on("error", () => { + if (!response.headersSent) response.writeHead(502); + response.end("Connect worker is starting\n"); + }); + request.pipe(upstream); + }); + + proxy.on("upgrade", (request, socket, head) => { + const upstreamRequest = httpRequest({ + hostname: "127.0.0.1", + port: workerPort, + method: request.method, + path: request.url, + headers: proxyHeaders(request, routingToken), + }); + socket.on("error", () => upstreamRequest.destroy()); + upstreamRequest.on("upgrade", (response, upstreamSocket, upstreamHead) => { + upstreamSocket.on("error", () => socket.destroy()); + writeUpgradeHead(socket, response); + if (head.length > 0) upstreamSocket.write(head); + if (upstreamHead.length > 0) socket.write(upstreamHead); + socket.pipe(upstreamSocket).pipe(socket); + }); + upstreamRequest.on("response", (response) => { + writeUpgradeHead(socket, response); + response.pipe(socket); + }); + upstreamRequest.on("error", () => socket.destroy()); + upstreamRequest.end(); + }); + + await new Promise((resolve, reject) => { + proxy.once("error", reject); + proxy.listen(publicPort, "127.0.0.1", resolve); + }); + return proxy; +} + +function parseDevVars(filePath) { + const values = new Map(); + for (const rawLine of readFileSync(filePath, "utf8").split(/\r?\n/gu)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#")) continue; + const separator = line.indexOf("="); + if (separator <= 0) continue; + const key = line.slice(0, separator).trim(); + let value = line.slice(separator + 1).trim(); + const quote = value[0]; + if ( + (quote === '"' || quote === "'" || quote === "`") && + value.endsWith(quote) + ) { + value = value.slice(1, -1); + } + values.set(key, value); + } + return values; +} + +function runPnpm(args, options = {}) { + const result = spawnSync("pnpm", args, { + cwd: REPO_ROOT, + env: { ...process.env, ...options.env }, + stdio: "inherit", + }); + if (result.error) fail(result.error.message); + if (result.status !== 0) { + fail(`command failed: pnpm ${args.join(" ")}`); + } +} + +function readManagedDevEnvValue(key) { + if (!existsSync(DEV_ENV_PATH)) return undefined; + let parsed; + try { + parsed = JSON.parse(readFileSync(DEV_ENV_PATH, "utf8")); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + fail(`could not read ${DEV_ENV_PATH} (${detail})`); + } + const value = parsed?.env?.[key]; + if (value === undefined) return undefined; + if (typeof value !== "string") { + fail(`${key} in ${DEV_ENV_PATH} must be a string`); + } + return value; +} + +function updateManagedDevEnv(action, key, value) { + const commandArgs = [ + "--conditions=source", + "--import", + "tsx", + BB_APP_SOURCE_BIN, + "--data-dir", + DEV_INSTANCE.dataDir, + "--server-url", + DEV_INSTANCE.serverUrl, + "env", + action, + key, + ]; + if (value !== undefined) commandArgs.push(value); + const result = spawnSync(process.execPath, commandArgs, { + cwd: REPO_ROOT, + env: process.env, + encoding: "utf8", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = (result.stderr || result.stdout).trim(); + throw new Error( + detail.length > 0 + ? detail + : `bb-app env ${action} exited with ${String(result.status)}`, + ); + } +} + +function seedDevUserSql() { + const now = Date.now(); + return `INSERT INTO user (id, name, email, email_verified, github_login, created_at, updated_at) VALUES ('${SEEDED_USER_ID}', 'Local bb developer', 'cloud-dev@local.invalid', 1, 'localbb', ${now}, ${now}) ON CONFLICT(id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at`; +} + +function spawnService(label, args, env = {}) { + const childEnv = { ...process.env, ...env }; + for (const [key, value] of Object.entries(childEnv)) { + if (value === undefined) delete childEnv[key]; + } + const child = spawn("pnpm", args, { + cwd: REPO_ROOT, + detached: process.platform !== "win32", + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + for (const [stream, writer] of [ + [child.stdout, process.stdout], + [child.stderr, process.stderr], + ]) { + stream.setEncoding("utf8"); + let pending = ""; + stream.on("data", (chunk) => { + pending += chunk; + const lines = pending.split("\n"); + pending = lines.pop() ?? ""; + for (const line of lines) writer.write(`[${label}] ${line}\n`); + }); + stream.on("end", () => { + if (pending.length > 0) writer.write(`[${label}] ${pending}\n`); + }); + } + return child; +} + +function stopService(child) { + if (child.exitCode !== null || child.signalCode !== null) return; + try { + if (process.platform === "win32") child.kill("SIGTERM"); + else process.kill(-child.pid, "SIGTERM"); + } catch { + // The process may have exited between the state check and the signal. + } +} + +async function waitForHttp(url, label, child) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`${label} exited before becoming ready`); + } + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(1_000), + }); + if (response.status < 500) return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw new Error(`${label} did not become ready within 30 seconds`); +} + +const { + connectPort: CONNECT_PORT, + webPort: WEB_PORT, + githubAuth: GITHUB_AUTH, +} = parseArgs(process.argv.slice(2)); + +if (!existsSync(CONNECT_VARS_PATH)) { + fail( + "apps/connect/.dev.vars is missing; add OPENAI_API_KEY and BETTER_AUTH_SECRET first", + ); +} +const connectVars = parseDevVars(CONNECT_VARS_PATH); +for (const key of ["OPENAI_API_KEY", "BETTER_AUTH_SECRET"]) { + const value = connectVars.get(key); + if (!value || value.startsWith("replace-with-")) + fail(`${key} is missing or still a placeholder in apps/connect/.dev.vars`); +} + +let githubClientId = "local-cloud-dev-unused"; +let githubClientSecret = "local-cloud-dev-unused"; +if (GITHUB_AUTH) { + if (!existsSync(WEB_VARS_PATH)) { + fail( + "apps/web/.dev.vars is missing; copy .dev.vars.example and add the local GitHub OAuth credentials", + ); + } + const webVars = parseDevVars(WEB_VARS_PATH); + githubClientId = webVars.get("GITHUB_CLIENT_ID") ?? ""; + githubClientSecret = webVars.get("GITHUB_CLIENT_SECRET") ?? ""; + for (const [key, value] of [ + ["GITHUB_CLIENT_ID", githubClientId], + ["GITHUB_CLIENT_SECRET", githubClientSecret], + ]) { + if (!value || value.startsWith("replace-with-")) { + fail(`${key} is missing or still a placeholder in apps/web/.dev.vars`); + } + } +} + +await Promise.all([ + assertPortAvailable(CONNECT_PORT, "Connect"), + assertPortAvailable(WEB_PORT, "web"), +]); +const CONNECT_WORKER_PORT = await findEphemeralLoopbackPort(); +const DEV_ROUTING_TOKEN = randomBytes(32).toString("hex"); +const managedDevEnv = [ + { + key: CONNECT_BASE_URL_ENV_NAME, + value: `http://127.0.0.1:${WEB_PORT}`, + }, + { + key: CONNECT_LOOPBACK_URL_ENV_NAME, + value: `http://127.0.0.1:${DEV_INSTANCE.ports.appPort}`, + }, +]; +const previousManagedDevEnv = new Map( + managedDevEnv.map(({ key }) => [key, readManagedDevEnvValue(key)]), +); +await mkdir(STATE_DIR, { recursive: true }); + +console.log(`Preparing shared local D1 at ${STATE_DIR}`); +runPnpm( + [ + "--filter", + "@bb/connect", + "exec", + "wrangler", + "d1", + "migrations", + "apply", + "DB", + "--local", + "--persist-to", + STATE_DIR, + ], + { env: { CI: "1" } }, +); + +if (!GITHUB_AUTH) { + runPnpm([ + "--filter", + "@bb/connect", + "exec", + "wrangler", + "d1", + "execute", + "DB", + "--local", + "--persist-to", + STATE_DIR, + "--command", + seedDevUserSql(), + ]); +} + +const connect = spawnService("connect", [ + "--filter", + "@bb/connect", + "exec", + "wrangler", + "dev", + "--port", + String(CONNECT_WORKER_PORT), + "--ip", + "127.0.0.1", + "--host", + "getbb.app", + "--persist-to", + STATE_DIR, + "--var", + `BASE_DOMAIN:${BASE_DOMAIN}`, + "--var", + `ACCOUNT_APP_URL:http://127.0.0.1:${WEB_PORT}`, + ...(GITHUB_AUTH ? [] : ["--var", `DEV_AUTH_USER_ID:${SEEDED_USER_ID}`]), + "--var", + `DEV_ROUTING_TOKEN:${DEV_ROUTING_TOKEN}`, + "--var", + "BETTER_AUTH_SESSION_COOKIE_NAME:better-auth.session_token", + "--show-interactive-dev-session=false", +]); + +const connectProxy = await startConnectLoopbackProxy({ + publicPort: CONNECT_PORT, + workerPort: CONNECT_WORKER_PORT, + routingToken: DEV_ROUTING_TOKEN, +}); + +const web = spawnService( + "web", + [ + "--filter", + "@bb/web", + "exec", + "vite", + "dev", + "--host", + "0.0.0.0", + "--port", + String(WEB_PORT), + ], + { + BB_CLOUD_DEV_APP_URL: `http://127.0.0.1:${WEB_PORT}`, + BB_CLOUD_DEV_BASE_DOMAIN: BASE_DOMAIN, + BB_CLOUD_DEV_CONNECT_SERVER_URL_TEMPLATE: `http://{label}.${BASE_DOMAIN}:${CONNECT_PORT}`, + BB_CLOUD_DEV_STATE_PATH: STATE_DIR, + BETTER_AUTH_SECRET: connectVars.get("BETTER_AUTH_SECRET"), + CLOUDFLARE_ENV: "production", + CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", + GITHUB_CLIENT_ID: githubClientId, + GITHUB_CLIENT_SECRET: githubClientSecret, + DEV_AUTH_USER_ID: GITHUB_AUTH ? undefined : SEEDED_USER_ID, + LANDING_POSTHOG_KEY: "local-cloud-dev-unused", + RESEND_API_KEY: "local-cloud-dev-unused", + }, +); + +let stopping = false; +const appliedManagedDevEnvKeys = []; + +function restoreManagedDevEnv() { + while (appliedManagedDevEnvKeys.length > 0) { + const key = appliedManagedDevEnvKeys.pop(); + try { + const previousValue = previousManagedDevEnv.get(key); + if (previousValue === undefined) { + updateManagedDevEnv("unset", key); + } else { + updateManagedDevEnv("set", key, previousValue); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.error(`Could not restore ${key} in the dev server: ${detail}`); + } + } +} + +function stopAll(exitCode = 0) { + if (stopping) return; + stopping = true; + restoreManagedDevEnv(); + connectProxy.close(); + connectProxy.closeAllConnections(); + stopService(connect); + stopService(web); + setTimeout(() => process.exit(exitCode), 100); +} +process.on("SIGINT", () => stopAll(0)); +process.on("SIGTERM", () => stopAll(0)); +connect.on("exit", (exitCode) => { + if (!stopping) { + console.error(`Connect worker exited with code ${exitCode ?? "unknown"}`); + stopAll(exitCode ?? 1); + } +}); +web.on("exit", (exitCode) => { + if (!stopping) { + console.error(`Web worker exited with code ${exitCode ?? "unknown"}`); + stopAll(exitCode ?? 1); + } +}); + +try { + await Promise.all([ + waitForHttp( + `http://127.0.0.1:${CONNECT_PORT}/api/connect/servers`, + "Connect worker", + connect, + ), + waitForHttp( + `http://127.0.0.1:${WEB_PORT}/api/connect/redeem`, + "Web worker", + web, + ), + ]); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + stopAll(1); + await new Promise(() => {}); +} + +try { + for (const { key, value } of managedDevEnv) { + updateManagedDevEnv("set", key, value); + appliedManagedDevEnvKeys.push(key); + } +} catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.error( + `Could not configure the dev server's Cloud environment: ${detail}`, + ); + stopAll(1); + await new Promise(() => {}); +} + +const authInstructions = GITHUB_AUTH + ? `Auth: GitHub OAuth + dashboard: http://127.0.0.1:${WEB_PORT}/dashboard + +Sign in with GitHub, claim a handle, and create a bb from the dashboard. The +OAuth callback registered at GitHub should omit the dynamic loopback port: + http://127.0.0.1/api/auth/callback/github +This run redirects back to: + http://127.0.0.1:${WEB_PORT}/api/auth/callback/github` + : `Auth: local developer identity + dashboard: http://127.0.0.1:${WEB_PORT}/dashboard + +Open the dashboard, choose a handle, and create your first bb. Generate its +pairing code there, then enter that code in the app: + http://localhost:${DEV_INSTANCE.ports.appPort}/settings/plugins/connect + +After pairing, enable Cloud AI from another terminal if you are testing it: + eval "$(scripts/bb-dev-app env)" + unset BB_CLI BB_CLI_REEXEC + pnpm bb:dev settings experiment cloudAi true + pnpm bb:dev connect ai on + pnpm bb:dev connect status`; + +console.log(` +Local bb Cloud is ready: + apps/web: http://127.0.0.1:${WEB_PORT} + apps/connect: http://127.0.0.1:${CONNECT_PORT} + bb URLs: http://.localhost:${CONNECT_PORT} + +${authInstructions} + +Press Ctrl-C to stop both Cloud workers and restore the dev server's previous +${CONNECT_BASE_URL_ENV_NAME} and ${CONNECT_LOOPBACK_URL_ENV_NAME} settings. +`); + +await new Promise(() => {}); From 705d727ebe11045bbb812ac696af0b8edaadb28d Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 7 Aug 2026 15:00:53 -0700 Subject: [PATCH 2/2] Add the Cloud plugin and AI gateway --- .../AppLayout.plugin-panel-header.test.tsx | 1 + .../AppLayout.root-compose-project.test.tsx | 1 + .../components/plugin/PluginSettings.test.tsx | 19 +- .../plugin/PluginSettingsSections.tsx | 52 +- .../PluginSidebarFooterActions.test.tsx | 38 +- .../plugin/PluginSidebarFooterActions.tsx | 17 +- .../settings/PluginsSettingsSection.test.tsx | 77 +- apps/app/src/lib/route-paths.ts | 10 +- apps/app/src/lib/system-config-atoms.ts | 1 + .../views/SettingsView.experiments.test.tsx | 10 + apps/app/src/views/SettingsView.stories.tsx | 7 + apps/app/src/views/SettingsView.tsx | 24 + .../__tests__/command-output/settings.test.ts | 17 + apps/cli/src/commands/settings.ts | 1 + apps/connect/src/ai-schema.test.ts | 84 + apps/connect/src/ai-schema.ts | 49 + apps/connect/src/ai.test.ts | 605 +++ apps/connect/src/ai.ts | 490 +++ apps/connect/src/machine-label.ts | 2 +- apps/connect/src/tunnel-do.ts | 2 + apps/connect/src/worker.test.ts | 2 + apps/connect/src/worker.ts | 7 + apps/connect/wrangler.jsonc | 2 + apps/desktop/test/preload-build.test.ts | 1 + apps/host-daemon/src/codex-chatgpt-client.ts | 2 + .../src/services/ai/cloud-ai-provider.ts | 46 + apps/server/src/services/ai/inference.ts | 78 + .../src/services/ai/voice-transcription.ts | 88 +- .../server/src/services/plugins/plugin-api.ts | 6 + .../skills/builtin-skills/bb-cli/SKILL.md | 33 +- .../bb-cli/references/app-settings.md | 11 + .../bb-plugin-authoring/SKILL.md | 31 +- apps/server/test/ai/inference.test.ts | 188 +- .../test/ai/voice-transcription.test.ts | 177 +- .../services/plugins/builtin-plugins.test.ts | 13 +- .../plugins/plugin-authoring-docs.test.ts | 1 + apps/server/test/system/experiments.test.ts | 6 + .../threads/thread-runtime-config.test.ts | 1 + docs/api_to_audit.md | 34 +- docs/configuration.md | 74 +- packages/connect-client/src/ai.ts | 152 + packages/connect-client/src/index.ts | 6 + .../test/connect-client.test.ts | 107 + .../connect-db/migrations/0006_ai_usage.sql | 7 + .../migrations/meta/0006_snapshot.json | 920 +++++ .../connect-db/migrations/meta/_journal.json | 7 + packages/connect-db/src/constants.ts | 15 + packages/connect-db/src/schema.ts | 21 + packages/db/drizzle/0088_minor_juggernaut.sql | 1 + packages/db/drizzle/meta/0088_snapshot.json | 3436 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/data/experiments.ts | 3 + packages/db/src/schema.ts | 1 + packages/db/test/migrate.test.ts | 18 + packages/domain/src/experiments.ts | 6 + packages/plugin-registry/r/icon.json | 2 +- .../bundled-types/bb-plugin-sdk-app.d.ts | 7 +- .../bundled-types/bb-plugin-sdk.d.ts | 130 +- .../src/__tests__/public-types.test.ts | 6 + packages/plugin-sdk/src/app-contract.ts | 5 +- packages/plugin-sdk/src/backend-contract.ts | 58 + .../src/testing/fake-plugin-host.ts | 5 + packages/shared-ui/src/components/ui/icon.tsx | 1 + .../src/generated/plugin-sdk-dts.generated.ts | 4 +- .../plugin-starter-files.generated.ts | 2 +- .../src/generated/templates.generated.ts | 2 +- .../src/templates/bb-guide-environments.md | 43 +- plugins/connect/app.test.tsx | 110 +- plugins/connect/app.tsx | 185 +- plugins/connect/package.json | 8 +- plugins/connect/src/cli.ts | 67 +- plugins/connect/src/cloud-ai.ts | 148 + plugins/connect/src/connect.test.ts | 224 +- plugins/connect/src/rpc.ts | 11 + plugins/connect/src/server.ts | 24 +- plugins/connect/src/tunnel.ts | 3 + plugins/connect/src/types.ts | 6 + 77 files changed, 7870 insertions(+), 196 deletions(-) create mode 100644 apps/connect/src/ai-schema.test.ts create mode 100644 apps/connect/src/ai-schema.ts create mode 100644 apps/connect/src/ai.test.ts create mode 100644 apps/connect/src/ai.ts create mode 100644 apps/server/src/services/ai/cloud-ai-provider.ts create mode 100644 packages/connect-client/src/ai.ts create mode 100644 packages/connect-db/migrations/0006_ai_usage.sql create mode 100644 packages/connect-db/migrations/meta/0006_snapshot.json create mode 100644 packages/db/drizzle/0088_minor_juggernaut.sql create mode 100644 packages/db/drizzle/meta/0088_snapshot.json create mode 100644 plugins/connect/src/cloud-ai.ts diff --git a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx index 7dff45cb33..ba54eb2796 100644 --- a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx +++ b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx @@ -28,6 +28,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({ data: { experiments: { claudeCodeMockCliTraffic: false, + cloudAi: false, newOnboarding: false, toolsHub: true, }, diff --git a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx index b2f91fa6c8..98e8cebdf2 100644 --- a/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx +++ b/apps/app/src/components/layout/AppLayout.root-compose-project.test.tsx @@ -24,6 +24,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({ data: { experiments: { claudeCodeMockCliTraffic: false, + cloudAi: false, newOnboarding: false, toolsHub: true, }, diff --git a/apps/app/src/components/plugin/PluginSettings.test.tsx b/apps/app/src/components/plugin/PluginSettings.test.tsx index 68d1ddbdaa..60973357d7 100644 --- a/apps/app/src/components/plugin/PluginSettings.test.tsx +++ b/apps/app/src/components/plugin/PluginSettings.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { @@ -283,14 +284,16 @@ describe("PluginSettingsDetail settings gating", () => { }); const { wrapper } = createQueryClientTestHarness(); render( - , + + + , { wrapper }, ); diff --git a/apps/app/src/components/plugin/PluginSettingsSections.tsx b/apps/app/src/components/plugin/PluginSettingsSections.tsx index 92b9101ab0..c2da68444d 100644 --- a/apps/app/src/components/plugin/PluginSettingsSections.tsx +++ b/apps/app/src/components/plugin/PluginSettingsSections.tsx @@ -1,3 +1,6 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; import { usePluginSlots, type PluginSettingsSectionSlot, @@ -8,6 +11,20 @@ import { ResourceDetailConfigurationSection, } from "@bb/shared-ui/resource-list"; +const CONNECT_PLUGIN_ID = "connect"; +const CLOUD_AI_SECTION_ID = "cloud-ai"; + +function isSettingsSectionVisible( + section: PluginSettingsSectionSlot, + cloudAiEnabled: boolean, +): boolean { + return !( + section.pluginId === CONNECT_PLUGIN_ID && + section.id === CLOUD_AI_SECTION_ID && + !cloudAiEnabled + ); +} + /** * Plugin `settingsSection` slot mounts, rendered on that plugin's canonical * Plugins detail page below the host-rendered declarative form. @@ -15,8 +32,11 @@ import { */ export function PluginSettingsSections({ pluginId }: { pluginId: string }) { const { settingsSections } = usePluginSlots(); + const cloudAiEnabled = useSystemConfig().data?.experiments?.cloudAi === true; const sections = settingsSections.filter( - (section) => section.pluginId === pluginId, + (section) => + section.pluginId === pluginId && + isSettingsSectionVisible(section, cloudAiEnabled), ); if (sections.length === 0) return null; return ; @@ -27,16 +47,34 @@ function PluginSettingsSectionList({ }: { sections: readonly PluginSettingsSectionSlot[]; }) { + const location = useLocation(); + + useEffect(() => { + if (location.hash.length <= 1) return; + let sectionId: string; + try { + sectionId = decodeURIComponent(location.hash.slice(1)); + } catch { + return; + } + if (!sections.some((section) => section.id === sectionId)) return; + document.getElementById(sectionId)?.scrollIntoView({ block: "start" }); + }, [location.hash, location.key, sections]); + return (
{sections.map((section) => { const key = `${section.pluginId}/${section.id}/${section.generation}`; - return section.title === undefined ? ( - - ) : ( - - - + return ( +
+ {section.title === undefined ? ( + + ) : ( + + + + )} +
); })}
diff --git a/apps/app/src/components/plugin/PluginSidebarFooterActions.test.tsx b/apps/app/src/components/plugin/PluginSidebarFooterActions.test.tsx index 9ba8114110..a852460af8 100644 --- a/apps/app/src/components/plugin/PluginSidebarFooterActions.test.tsx +++ b/apps/app/src/components/plugin/PluginSidebarFooterActions.test.tsx @@ -32,7 +32,13 @@ function registrationSet( } function LocationProbe() { - return {useLocation().pathname}; + const location = useLocation(); + return ( + + {location.pathname} + {location.hash} + + ); } function renderWithProviders(ui: ReactNode, toolsHubEnabled = false) { @@ -56,7 +62,7 @@ afterEach(() => { }); describe("PluginSidebarFooterActions", () => { - it("prefers branding.icon over the logo and contribution icon", () => { + it("uses the action icon instead of the plugin branding icon", () => { setPluginLogoUrls( new Map([ [ @@ -87,8 +93,8 @@ describe("PluginSidebarFooterActions", () => { renderWithProviders(); - expect(document.querySelector('[data-icon="FileText"]')).not.toBeNull(); - expect(document.querySelector('[data-icon="Smartphone"]')).toBeNull(); + expect(document.querySelector('[data-icon="Smartphone"]')).not.toBeNull(); + expect(document.querySelector('[data-icon="FileText"]')).toBeNull(); expect(document.querySelector("img")).toBeNull(); }); @@ -143,4 +149,28 @@ describe("PluginSidebarFooterActions", () => { ); }, ); + + it("opens a specific plugin settings section", () => { + setPluginSlotRegistrations( + "cloud", + registrationSet({ + sidebarFooterActions: [ + { + id: "remote-access", + title: "Remote access", + icon: "Smartphone", + run: ({ openSettings }) => + openSettings({ sectionId: "remote-access" }), + }, + ], + }), + ); + + renderWithProviders(); + fireEvent.click(screen.getByRole("button", { name: "Remote access" })); + + expect(screen.getByLabelText("Current path").textContent).toBe( + "/settings/plugins/cloud#remote-access", + ); + }); }); diff --git a/apps/app/src/components/plugin/PluginSidebarFooterActions.tsx b/apps/app/src/components/plugin/PluginSidebarFooterActions.tsx index 49e253a954..186b5e1bd6 100644 --- a/apps/app/src/components/plugin/PluginSidebarFooterActions.tsx +++ b/apps/app/src/components/plugin/PluginSidebarFooterActions.tsx @@ -1,8 +1,9 @@ import { useNavigate } from "react-router-dom"; import { cn } from "@bb/shared-ui/lib/utils"; import { COARSE_POINTER_CHILD_ICON_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { Icon } from "@bb/shared-ui/icon"; import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar.js"; -import { PluginIcon } from "@/components/plugin/PluginIcon"; +import { pluginIconName } from "@/components/plugin/PluginIcon"; import { usePluginSlots, type PluginSidebarFooterActionSlot, @@ -63,7 +64,11 @@ function PluginSidebarFooterActionList({ }); }} > - +
); @@ -978,6 +976,69 @@ function ConnectedContent({ ); } +function CloudAiControl({ + status, + onChanged, +}: { + status: ConnectStatus; + onChanged: () => void; +}) { + const rpc = useRpc(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const setEnabled = useCallback( + (enabled: boolean) => { + setPending(true); + setError(null); + rpc.call("setCloudAi", { enabled }).then( + () => { + setPending(false); + onChanged(); + }, + (rpcError: unknown) => { + setPending(false); + setError(errorText(rpcError)); + }, + ); + }, + [rpc, onChanged], + ); + + if (!status.paired) { + return ( +

+ + Set up Remote access + {" "} + to use Cloud. +

+ ); + } + + return ( +
+
+

+ Enable for thread titles, commit messages and voice transcription +

+ +
+ {error !== null ? ( +

{error}

+ ) : null} +
+ ); +} + function ReconnectingContent({ status, onChanged, @@ -1071,15 +1132,14 @@ function ReconnectingContent({ } // --------------------------------------------------------------------------- -// Section root. +// Shared status lifecycle. Each independently mounted settings section reads +// the same RPC snapshot and applies full realtime snapshots without polling. // --------------------------------------------------------------------------- -function ConnectSettingsSection() { +function useConnectStatus() { const rpc = useRpc(); const [status, setStatus] = useState(null); const [loadError, setLoadError] = useState(null); - const [flash, setFlash] = useState(null); - const flashTimerRef = useRef | null>(null); const refetch = useCallback(() => { rpc.call("status").then( @@ -1110,6 +1170,18 @@ function ConnectSettingsSection() { } }); + return { loadError, refetch, status }; +} + +// --------------------------------------------------------------------------- +// Remote-access settings section. +// --------------------------------------------------------------------------- + +function RemoteAccessSettingsSection() { + const { loadError, refetch, status } = useConnectStatus(); + const [flash, setFlash] = useState(null); + const flashTimerRef = useRef | null>(null); + const showDisconnected = useCallback(() => { // Transient inline receipt (the SDK exposes no toast on this surface): // the silhouette-identical card swap no longer passes silently. @@ -1169,19 +1241,46 @@ function ConnectSettingsSection() { ); } +// --------------------------------------------------------------------------- +// Cloud-AI settings section. +// --------------------------------------------------------------------------- + +function CloudAiSettingsSection() { + const { loadError, refetch, status } = useConnectStatus(); + + if (loadError !== null) { + return ( +

+ Failed to load AI feature settings: {loadError} +

+ ); + } + if (status === null) { + return

Loading...

; + } + + return ; +} + export default definePluginApp((app) => { app.slots.settingsSection({ id: "remote-access", + title: "Remote access", description: "Use this bb from any device, anywhere — powered by getbb.app.", - component: ConnectSettingsSection, + component: RemoteAccessSettingsSection, + }); + app.slots.settingsSection({ + id: "cloud-ai", + title: "AI Gateway", + component: CloudAiSettingsSection, }); app.slots.sidebarFooterAction({ id: "remote-access", title: "Remote access", icon: "Smartphone", run({ openSettings }) { - openSettings(); + openSettings({ sectionId: "remote-access" }); }, }); }); diff --git a/plugins/connect/package.json b/plugins/connect/package.json index bcb7e5d612..ab5a669639 100644 --- a/plugins/connect/package.json +++ b/plugins/connect/package.json @@ -3,15 +3,15 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "Remote access via getbb.app \u2014 this bb becomes reachable at https://.getbb.app. Disable to cut off all remote access.", + "description": "Connect this bb to bb Cloud (getbb.app) for remote access, shared ports, and account-backed AI features. Disable to cut off every Cloud capability.", "engines": { "bb": ">=0.0" }, "bb": { - "name": "Remote access", - "description": "Remote access via getbb.app \u2014 this bb becomes reachable at https://.getbb.app. Disable to cut off all remote access.", + "name": "Cloud", + "description": "Connect this bb to bb Cloud (getbb.app) for remote access, shared ports, and account-backed AI features. Disable to cut off every Cloud capability.", "branding": { - "icon": "Smartphone" + "icon": "Cloud" }, "server": "./src/server.ts", "app": "./app.tsx" diff --git a/plugins/connect/src/cli.ts b/plugins/connect/src/cli.ts index e230be28ca..5a0f68a8ee 100644 --- a/plugins/connect/src/cli.ts +++ b/plugins/connect/src/cli.ts @@ -1,4 +1,5 @@ import type { BbPluginApi, PluginCliResult } from "@bb/plugin-sdk"; +import type { CloudAiController } from "./cloud-ai.js"; import type { ShareHostResolver } from "./hosts.js"; import { parseSharePort } from "./shares.js"; import type { ConnectTunnel } from "./tunnel.js"; @@ -63,15 +64,18 @@ function validateFlags( function helpText(): string { return [ - "Remote access via getbb.app — this bb becomes reachable at https://.getbb.app.", - "Share HTTP ports from any enrolled host (owner session only).", + "Connect this bb to bb Cloud (getbb.app). Once connected: access it from", + "anywhere at https://.getbb.app, share HTTP ports from enrolled", + "hosts, and AI features (thread titles, commit messages, voice", + "transcription) run through your account.", "", " 1. Sign in at https://getbb.app and claim a handle.", " 2. Copy the connect command from the dashboard and run it here:", " bb connect --code --server https://.getbb.app", "", - " bb connect status Show remote-access status", - " bb connect off Disconnect and forget the pairing (re-pairing needs a new code)", + " bb connect status Show bb Cloud connection status", + " bb connect off Unlink this bb from Cloud (re-pairing needs a new code)", + " bb connect ai [on|off] Show or set the AI features setting", " bb connect expose [--host ] Share a port from the thread's host", " bb connect unexpose [--host ] Stop sharing a port on that host", " bb connect shares [--host ] List shares for the thread's host", @@ -84,9 +88,10 @@ function helpText(): string { function formatStatus(status: ConnectStatus): string { if (!status.paired) { - return "Not paired\nPair from the getbb.app dashboard — run `bb connect` for a how-to."; + return "Not connected to bb Cloud\nPair from the getbb.app dashboard — run `bb connect` for a how-to."; } const lines = [`${status.handle} ${status.url} ${status.state}`]; + lines.push(` ai features: ${status.cloudAiEnabled ? "on" : "off"}`); if (status.lastError !== null && status.state !== "connected") { lines.push(` last error: ${status.lastError}`); } @@ -113,12 +118,13 @@ export function registerConnectCli(args: { bb: Pick; tunnel: ConnectTunnel; hostResolver: ShareHostResolver; + cloudAi: CloudAiController; }): void { - const { bb, tunnel, hostResolver } = args; + const { bb, tunnel, hostResolver, cloudAi } = args; bb.cli.register({ name: "connect", summary: - "Expose this bb at https://.getbb.app (pair with --code/--server from the dashboard)", + "Set up and manage this bb's Cloud connection (pair with --code/--server from the dashboard)", commands: [ { name: "status", @@ -127,9 +133,15 @@ export function registerConnectCli(args: { }, { name: "off", - summary: "Disconnect and forget the pairing", + summary: "Unlink this bb from Cloud and forget the pairing", usage: "bb connect off [--json]", }, + { + name: "ai", + summary: + "Show or set the AI features setting (titles, commit messages, voice via bb Cloud)", + usage: "bb connect ai [on|off] [--json]", + }, { name: "expose", summary: "Share an HTTP port from an enrolled host", @@ -173,7 +185,44 @@ export function registerConnectCli(args: { exitCode: 0, stdout: parsed.flags.has("json") ? asJson(status) - : "Disconnected\n", + : "Disconnected from bb Cloud\n", + }; + } + if (first === "ai") { + const value = argv[1]; + const flagStart = value === "on" || value === "off" ? 2 : 1; + if ( + value !== undefined && + value !== "on" && + value !== "off" && + !value.startsWith("--") + ) { + return { + exitCode: 1, + stderr: "Usage: bb connect ai [on|off] [--json]\n", + }; + } + const parsed = parseFlags(argv.slice(flagStart)); + validateFlags(parsed, { boolean: ["json"] }); + if (value === "on" || value === "off") { + await cloudAi.setCloudAiEnabled(value === "on"); + } + const status = tunnel.status(); + if (parsed.flags.has("json")) { + return { + exitCode: 0, + stdout: asJson({ + cloudAiEnabled: status.cloudAiEnabled, + paired: status.paired, + }), + }; + } + const effect = status.paired + ? "" + : " (no effect until this bb is connected to bb Cloud)"; + return { + exitCode: 0, + stdout: `AI features: ${status.cloudAiEnabled ? "on" : "off"}${effect}\n`, }; } if (first === "expose") { diff --git a/plugins/connect/src/cloud-ai.ts b/plugins/connect/src/cloud-ai.ts new file mode 100644 index 0000000000..ef6cff2d10 --- /dev/null +++ b/plugins/connect/src/cloud-ai.ts @@ -0,0 +1,148 @@ +import { + ConnectAiError, + fetchAiInference, + fetchAiTranscription, + type ConnectCredential, +} from "@bb/connect-client"; +import type { + CloudAiCompleteArgs, + CloudAiFailureCode, + CloudAiProvider, + CloudAiResult, + CloudAiTranscribeArgs, + JsonValue, + PluginKvStorage, + PluginLogger, +} from "@bb/plugin-sdk"; + +// bb Cloud AI routing for this bb: when paired and enabled, thread titles, +// commit messages, and voice transcription run through the gate's +// /api/connect/ai/* proxy instead of locally configured providers. Registered +// with the host via bb.experimental_registerCloudAiProvider; the host falls +// back to local providers on any `ok: false` result. + +export const CLOUD_AI_ENABLED_KV_KEY = "cloudAiEnabled"; + +/** Skip the cloud for a while after a budget 429 so exhaustion doesn't add a + * failed round-trip to every call. Transient failures are not latched — the + * host's local fallback already absorbs them. */ +const QUOTA_COOLDOWN_MS = 5 * 60 * 1000; + +export interface CloudAiControllerOptions { + kv: Pick; + /** Live pairing credential (the tunnel's), or null when unpaired. */ + getCredential: () => ConnectCredential | null; + log: PluginLogger; + /** Fired when the enabled setting changes (drives status/realtime pushes). */ + onChange?: () => void; + fetchImpl?: typeof fetch; + now?: () => number; +} + +export class CloudAiController implements CloudAiProvider { + private enabled = true; + /** Credential string the gate rejected (401/403); cleared naturally when + * re-pairing stores a different credential. */ + private rejectedCredential: string | null = null; + /** Epoch ms until which budget-exhausted cloud calls are skipped. */ + private quotaCooldownUntil = 0; + + constructor(private readonly options: CloudAiControllerOptions) {} + + /** Load the persisted setting; missing key means enabled (the default). */ + async init(): Promise { + const stored = await this.options.kv.get(CLOUD_AI_ENABLED_KV_KEY); + this.enabled = stored ?? true; + } + + cloudAiEnabled(): boolean { + return this.enabled; + } + + async setCloudAiEnabled(enabled: boolean): Promise { + this.enabled = enabled; + await this.options.kv.set(CLOUD_AI_ENABLED_KV_KEY, enabled); + this.options.onChange?.(); + } + + isAvailable(): boolean { + if (!this.enabled) return false; + const credential = this.options.getCredential(); + if (credential === null) return false; + if (this.rejectedCredential === credential.credential) return false; + if (this.now() < this.quotaCooldownUntil) return false; + return true; + } + + async complete(args: CloudAiCompleteArgs): Promise> { + const credential = this.options.getCredential(); + if (credential === null) { + return { ok: false, code: "unavailable", message: "not paired" }; + } + try { + const value = await fetchAiInference( + credential, + { prompt: args.prompt, schema: args.schema, signal: args.signal }, + this.options.fetchImpl ?? globalThis.fetch, + ); + // Wire boundary: the gate returned zod-validated JSON, so the record's + // values are JsonValue by construction. + return { ok: true, value: value as JsonValue }; + } catch (error) { + return this.failure(credential, error); + } + } + + async transcribe(args: CloudAiTranscribeArgs): Promise> { + const credential = this.options.getCredential(); + if (credential === null) { + return { ok: false, code: "unavailable", message: "not paired" }; + } + try { + const text = await fetchAiTranscription( + credential, + { + file: args.file, + ...(args.prompt !== undefined ? { prompt: args.prompt } : {}), + signal: args.signal, + }, + this.options.fetchImpl ?? globalThis.fetch, + ); + return { ok: true, value: text }; + } catch (error) { + return this.failure(credential, error); + } + } + + private now(): number { + return this.options.now?.() ?? Date.now(); + } + + private failure( + credential: ConnectCredential, + error: unknown, + ): CloudAiResult { + if (!(error instanceof ConnectAiError)) { + // Aborts (host timeout) and unexpected bugs propagate; the host owns + // both cases. + throw error; + } + if (error.code === "unauthorized") { + this.rejectedCredential = credential.credential; + this.options.log.warn( + "bb Cloud rejected the pairing credential; cloud AI paused until re-pair", + ); + return { ok: false, code: "unauthorized", message: error.message }; + } + if (error.code === "quota_exhausted") { + this.quotaCooldownUntil = this.now() + QUOTA_COOLDOWN_MS; + this.options.log.info( + "bb Cloud AI daily budget reached; using local providers for a while", + ); + return { ok: false, code: "quota_exhausted", message: error.message }; + } + this.options.log.warn(`bb Cloud AI call failed: ${error.message}`); + const code: CloudAiFailureCode = "unavailable"; + return { ok: false, code, message: error.message }; + } +} diff --git a/plugins/connect/src/connect.test.ts b/plugins/connect/src/connect.test.ts index dee5da523a..3db2097e80 100644 --- a/plugins/connect/src/connect.test.ts +++ b/plugins/connect/src/connect.test.ts @@ -21,6 +21,7 @@ import { SHARES_KV_KEY, serverOwnPort, } from "./shares.js"; +import { CloudAiController, CLOUD_AI_ENABLED_KV_KEY } from "./cloud-ai.js"; import { CREDENTIAL_KV_KEY } from "./credential.js"; import plugin from "./server.js"; import { ConnectTunnel } from "./tunnel.js"; @@ -2354,11 +2355,11 @@ describe("connect CLI", () => { const { harness } = await loadCli(); const before = await harness.runCli(["status"]); expect(before.exitCode).toBe(0); - expect(before.stdout).toContain("Not paired"); + expect(before.stdout).toContain("Not connected to bb Cloud"); const off = await harness.runCli(["off"]); expect(off.exitCode).toBe(0); - expect(off.stdout).toContain("Disconnected"); + expect(off.stdout).toContain("Disconnected from bb Cloud"); }); it("unknown subcommands fail with help", async () => { @@ -2592,3 +2593,222 @@ describe("connect CLI", () => { ]); }); }); + +describe("CloudAiController", () => { + const CREDENTIAL = { + serverUrl: "https://sawyer.getbb.app", + handle: "sawyer", + credential: "bbcred_secret", + }; + + function makeController(over: { + credential?: typeof CREDENTIAL | null; + fetchImpl?: typeof fetch; + now?: () => number; + store?: Map; + }) { + const store = over.store ?? new Map(); + const controller = new CloudAiController({ + kv: { + get: async (key: string) => store.get(key) as T | undefined, + set: async (key: string, value: unknown) => { + store.set(key, value); + }, + }, + getCredential: () => + over.credential === undefined ? CREDENTIAL : over.credential, + log: { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + }, + ...(over.fetchImpl ? { fetchImpl: over.fetchImpl } : {}), + ...(over.now ? { now: over.now } : {}), + }); + return { controller, store }; + } + + const completeArgs = { + prompt: "Generate a title", + schema: { type: "object" as const }, + signal: new AbortController().signal, + }; + + it("persists the enabled setting through kv and init", async () => { + const { controller, store } = makeController({}); + await controller.init(); + expect(controller.cloudAiEnabled()).toBe(true); + await controller.setCloudAiEnabled(false); + expect(store.get(CLOUD_AI_ENABLED_KV_KEY)).toBe(false); + + const { controller: reloaded } = makeController({ store }); + await reloaded.init(); + expect(reloaded.cloudAiEnabled()).toBe(false); + expect(reloaded.isAvailable()).toBe(false); + }); + + it("is unavailable while unpaired and available when paired", async () => { + const { controller: unpaired } = makeController({ credential: null }); + await unpaired.init(); + expect(unpaired.isAvailable()).toBe(false); + + const { controller: paired } = makeController({}); + await paired.init(); + expect(paired.isAvailable()).toBe(true); + }); + + it("completes through the gate and returns the structured value", async () => { + const { controller } = makeController({ + fetchImpl: (async () => + Response.json({ value: { title: "Cloud title" } })) as typeof fetch, + }); + await controller.init(); + await expect(controller.complete(completeArgs)).resolves.toEqual({ + ok: true, + value: { title: "Cloud title" }, + }); + }); + + it("latches a rejected credential until re-pairing changes it", async () => { + let credential = CREDENTIAL; + const { controller } = makeController({ + fetchImpl: (async () => + new Response("{}", { status: 401 })) as typeof fetch, + }); + const latching = new CloudAiController({ + kv: { + get: async () => undefined, + set: async () => undefined, + }, + getCredential: () => credential, + log: { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + }, + fetchImpl: (async () => + new Response("{}", { status: 401 })) as typeof fetch, + }); + await latching.init(); + void controller; + + const failed = await latching.complete(completeArgs); + expect(failed).toMatchObject({ ok: false, code: "unauthorized" }); + expect(latching.isAvailable()).toBe(false); + + // Re-pair writes a fresh credential — the latch clears naturally. + credential = { ...CREDENTIAL, credential: "bbcred_new" }; + expect(latching.isAvailable()).toBe(true); + }); + + it("cools down after budget exhaustion and recovers", async () => { + let now = 1_000_000; + const { controller } = makeController({ + fetchImpl: (async () => + new Response("{}", { status: 429 })) as typeof fetch, + now: () => now, + }); + await controller.init(); + + const failed = await controller.complete(completeArgs); + expect(failed).toMatchObject({ ok: false, code: "quota_exhausted" }); + expect(controller.isAvailable()).toBe(false); + now += 5 * 60 * 1000 + 1; + expect(controller.isAvailable()).toBe(true); + }); + + it("propagates aborts so the host keeps its timeout semantics", async () => { + const { controller } = makeController({ + fetchImpl: (async () => { + throw new DOMException("aborted", "AbortError"); + }) as typeof fetch, + }); + await controller.init(); + await expect(controller.complete(completeArgs)).rejects.toMatchObject({ + name: "AbortError", + }); + }); + + it("transcribes through the gate and maps transient failures", async () => { + const file = new File(["audio"], "recording.webm", { type: "audio/webm" }); + const signal = new AbortController().signal; + const { controller } = makeController({ + fetchImpl: (async () => Response.json({ text: "hello" })) as typeof fetch, + }); + await controller.init(); + await expect(controller.transcribe({ file, signal })).resolves.toEqual({ + ok: true, + value: "hello", + }); + + const { controller: failing } = makeController({ + fetchImpl: (async () => + new Response("oops", { status: 500 })) as typeof fetch, + }); + await failing.init(); + await expect(failing.transcribe({ file, signal })).resolves.toMatchObject({ + ok: false, + code: "unavailable", + }); + // Transient failures never latch availability. + expect(failing.isAvailable()).toBe(true); + }); +}); + +describe("connect CLI ai subcommand", () => { + let host: FakePluginHost | undefined; + + afterEach(async () => { + if (host) { + const { controller, done } = host.harness.runService("tunnel"); + controller.abort(); + await done; + await host.harness.dispose(); + host = undefined; + } + vi.unstubAllGlobals(); + }); + + async function loadCli(): Promise { + host = createConnectFakeHost(); + await plugin(host.bb as unknown as Parameters[0]); + return host; + } + + it("shows and sets the AI features setting, persisted in kv", async () => { + const { harness, bb } = await loadCli(); + const show = await harness.runCli(["ai"]); + expect(show.exitCode).toBe(0); + expect(show.stdout).toContain("AI features: on"); + expect(show.stdout).toContain("no effect until"); + + const off = await harness.runCli(["ai", "off"]); + expect(off.exitCode).toBe(0); + expect(off.stdout).toContain("AI features: off"); + await expect(bb.storage.kv.get(CLOUD_AI_ENABLED_KV_KEY)).resolves.toBe( + false, + ); + + const json = await harness.runCli(["ai", "--json"]); + expect(JSON.parse(json.stdout)).toEqual({ + cloudAiEnabled: false, + paired: false, + }); + + const invalid = await harness.runCli(["ai", "sideways"]); + expect(invalid.exitCode).toBe(1); + }); + + it("round-trips the setting over rpc and reports it in status", async () => { + const { harness } = await loadCli(); + const before = (await harness.callRpc("status")) as ConnectStatus; + expect(before.cloudAiEnabled).toBe(true); + + const after = (await harness.callRpc("setCloudAi", { + enabled: false, + })) as ConnectStatus; + expect(after.cloudAiEnabled).toBe(false); + }); +}); diff --git a/plugins/connect/src/rpc.ts b/plugins/connect/src/rpc.ts index c00f7e1243..48dced8d66 100644 --- a/plugins/connect/src/rpc.ts +++ b/plugins/connect/src/rpc.ts @@ -5,6 +5,7 @@ import { type DesktopSession, type ListAccountServersResult, } from "@bb/connect-client"; +import type { CloudAiController } from "./cloud-ai.js"; import { ConnectPairError } from "./redeem.js"; import type { ConnectTunnel } from "./tunnel.js"; import type { ConnectStatus } from "./types.js"; @@ -55,6 +56,7 @@ const connectStatusSchema: z.ZodType = z remoteClients: z.number().int(), lastRemoteActivityAt: z.number().nullable(), shares: z.array(connectShareStatusSchema), + cloudAiEnabled: z.boolean(), }) .strict(); @@ -133,6 +135,10 @@ export const connectRpcContract = defineRpcContract({ input: revokeMachineInputSchema, output: z.object({ ok: z.literal(true) }).strict(), }, + setCloudAi: { + input: z.object({ enabled: z.boolean() }).strict(), + output: connectStatusSchema, + }, }); export type ConnectRpcHandlers = PluginRpcHandlers; @@ -140,6 +146,7 @@ export type ConnectRpcHandlers = PluginRpcHandlers; export function createRpcHandlers( tunnel: ConnectTunnel, hostResolver: ShareHostResolver, + cloudAi: CloudAiController, ): ConnectRpcHandlers { return { async pair(args) { @@ -216,5 +223,9 @@ export function createRpcHandlers( await tunnel.revokeMachine(args.machineId); return { ok: true }; }, + async setCloudAi(args) { + await cloudAi.setCloudAiEnabled(args.enabled); + return tunnel.status(); + }, }; } diff --git a/plugins/connect/src/server.ts b/plugins/connect/src/server.ts index 701d9421e1..55f4caeb39 100644 --- a/plugins/connect/src/server.ts +++ b/plugins/connect/src/server.ts @@ -1,5 +1,6 @@ import type { BbPluginApi } from "@bb/plugin-sdk"; import { registerConnectCli } from "./cli.js"; +import { CloudAiController } from "./cloud-ai.js"; import { createKvCredentialStore } from "./credential.js"; import { connectRpcContract, createRpcHandlers } from "./rpc.js"; import { ShareRegistry } from "./shares.js"; @@ -34,6 +35,15 @@ export default async function plugin(bb: BbPluginApi) { }, }); + const cloudAi = new CloudAiController({ + kv: bb.storage.kv, + getCredential: () => tunnel.getCredential(), + log: bb.log, + onChange: () => + bb.realtime.publish(CONNECT_REALTIME_CHANNEL, tunnel.status()), + }); + await cloudAi.init(); + tunnel = new ConnectTunnel({ store, shares, @@ -42,10 +52,20 @@ export default async function plugin(bb: BbPluginApi) { log: bb.log, onStatusChange: (status) => bb.realtime.publish(CONNECT_REALTIME_CHANNEL, status), + getCloudAiEnabled: () => cloudAi.cloudAiEnabled(), }); - bb.rpc.register(connectRpcContract, createRpcHandlers(tunnel, hostResolver)); - registerConnectCli({ bb, tunnel, hostResolver }); + // While paired (and the AI features setting is on), thread titles, commit + // messages, and voice transcription route through bb Cloud; the host falls + // back to locally configured providers on failure. Unregistered with this + // load's dispose hooks, so disabling the plugin severs cloud AI too. + bb.experimental_registerCloudAiProvider(cloudAi); + + bb.rpc.register( + connectRpcContract, + createRpcHandlers(tunnel, hostResolver, cloudAi), + ); + registerConnectCli({ bb, tunnel, hostResolver, cloudAi }); bb.agents.contributeInstructions(() => { const status = tunnel.status(); diff --git a/plugins/connect/src/tunnel.ts b/plugins/connect/src/tunnel.ts index bca0f8cd21..502c22682a 100644 --- a/plugins/connect/src/tunnel.ts +++ b/plugins/connect/src/tunnel.ts @@ -60,6 +60,8 @@ export interface ConnectTunnelOptions { log: PluginLogger; /** Fired on every state/handle/error/shares/presence transition. */ onStatusChange?: (status: ConnectStatus) => void; + /** The "AI features" setting (CloudAiController); defaults to true. */ + getCloudAiEnabled?: () => boolean; } /** @@ -246,6 +248,7 @@ export class ConnectTunnel { remoteClients: this.remoteClients, lastRemoteActivityAt: this.lastRemoteActivityAt, shares, + cloudAiEnabled: this.options.getCloudAiEnabled?.() ?? true, }; } diff --git a/plugins/connect/src/types.ts b/plugins/connect/src/types.ts index fd8d80fc98..aed97a76a4 100644 --- a/plugins/connect/src/types.ts +++ b/plugins/connect/src/types.ts @@ -64,6 +64,12 @@ export interface ConnectStatus { lastRemoteActivityAt: number | null; /** Currently registered port shares (URL requires a pairing). */ shares: ConnectShareStatus[]; + /** + * The "AI features" setting: route thread titles, commit messages, and + * voice transcription through bb Cloud while paired. User preference — + * true even while unpaired (it simply has no effect then). + */ + cloudAiEnabled: boolean; } export const CONNECT_REALTIME_CHANNEL = "connect";