From 9e1556e0df916cc329bce793721ed6c0a8f949b7 Mon Sep 17 00:00:00 2001
From: Michael Yong
Date: Fri, 7 Aug 2026 14:59:46 -0700
Subject: [PATCH 1/4] 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 | 18 +-
apps/app/src/vite-env.d.ts | 1 +
apps/app/vite.dev.config.ts | 1 +
apps/connect/.dev.vars.example | 2 +
apps/connect/src/cloud-dev.test.ts | 80 +++++
apps/connect/src/cloud-dev.ts | 110 +++++++
apps/connect/src/protocol-headers.ts | 4 +
apps/connect/src/servers.test.ts | 15 +
apps/connect/src/servers.ts | 17 +-
apps/connect/src/tunnel-do.ts | 6 +-
apps/connect/src/worker.test.ts | 8 +-
apps/connect/src/worker.ts | 43 ++-
.../src/connect-tunnel/connect-tunnel.test.ts | 28 +-
apps/host-daemon/src/connect-tunnel/index.ts | 14 +-
.../server/src/services/plugins/plugin-api.ts | 5 +-
.../skills/builtin-skills/bb-cli/SKILL.md | 4 +
.../server/test/app/host-shared-ports.test.ts | 15 +-
.../test/services/plugins/plugin-sdk.test.ts | 1 +
apps/web/src/lib/connect-return-to.test.ts | 9 +
apps/web/src/routes/dashboard.tsx | 216 +++++++++++--
apps/web/src/server/api.test.ts | 19 ++
apps/web/src/server/api.ts | 89 ++++--
apps/web/src/server/auth.ts | 13 +-
apps/web/src/server/current-user.server.ts | 3 +-
apps/web/src/server/env.ts | 2 +
apps/web/src/server/fns.ts | 13 +-
apps/web/src/server/local-auth.test.ts | 39 +++
apps/web/src/server/local-auth.ts | 29 ++
apps/web/vite.config.ts | 35 ++-
docs/configuration.md | 8 +
docs/debugging-and-qa.md | 27 ++
package.json | 2 +
packages/config/src/runtime.ts | 15 +
packages/host-daemon-contract/src/commands.ts | 4 +-
.../test/contract.test.ts | 23 +-
.../bundled-types/bb-plugin-sdk.d.ts | 4 +
.../scripts/test/dev-instance-expectations.ts | 12 +
packages/scripts/test/run-dev.test.ts | 5 +-
.../src/generated/plugin-sdk-dts.generated.ts | 2 +-
.../src/generated/templates.generated.ts | 2 +-
.../src/templates/bb-guide-environments.md | 5 +
plugins/connect/app.test.tsx | 16 +
plugins/connect/app.tsx | 17 +-
plugins/connect/src/connect.test.ts | 165 +++++++++-
plugins/connect/src/local-loopback.test.ts | 16 +
plugins/connect/src/local-loopback.ts | 20 ++
plugins/connect/src/redeem.ts | 35 +++
plugins/connect/src/server.ts | 12 +-
plugins/connect/src/shares.ts | 4 +-
plugins/connect/src/tunnel-lifecycle.test.ts | 2 +
plugins/connect/src/tunnel.ts | 14 +-
pnpm-lock.yaml | 31 +-
scripts/bb-cloud-dev.mjs | 288 ++++++++++++++++++
54 files changed, 1452 insertions(+), 136 deletions(-)
create mode 100644 apps/connect/.dev.vars.example
create mode 100644 apps/connect/src/cloud-dev.test.ts
create mode 100644 apps/connect/src/cloud-dev.ts
create mode 100644 apps/connect/src/protocol-headers.ts
create mode 100644 apps/web/src/server/local-auth.test.ts
create mode 100644 apps/web/src/server/local-auth.ts
create mode 100644 plugins/connect/src/local-loopback.test.ts
create mode 100644 plugins/connect/src/local-loopback.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..e01783eae9 100644
--- a/apps/app/src/lib/dev-websocket-url.test.ts
+++ b/apps/app/src/lib/dev-websocket-url.test.ts
@@ -7,6 +7,7 @@ function installWindowLocation(url: string): void {
location: {
host: location.host,
hostname: location.hostname,
+ port: location.port,
protocol: location.protocol,
},
});
@@ -19,6 +20,7 @@ describe("buildDevWebSocketUrl", () => {
it("connects directly to the backend for HTTP source dev", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation("http://devbox.local:15802/threads/thr_1");
expect(buildDevWebSocketUrl({ path: "/ws" })).toBe(
@@ -28,6 +30,7 @@ describe("buildDevWebSocketUrl", () => {
it("uses the proxied app origin for HTTPS bb connect shares", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation(
"https://sawyer--15802.getbb.app/threads/thr_jew2ruik89",
);
@@ -37,13 +40,24 @@ describe("buildDevWebSocketUrl", () => {
);
});
+ it("uses the proxied app origin for HTTP local Cloud", () => {
+ vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
+ installWindowLocation("http://sawyer.localhost:35802/threads/thr_1");
+
+ expect(buildDevWebSocketUrl({ path: "/ws" })).toBe(
+ "ws://sawyer.localhost:35802/ws",
+ );
+ });
+
it("preserves terminal websocket paths on the proxied app origin", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation("https://dev.example.test:15802/threads/thr_1");
- expect(
- buildDevWebSocketUrl({ path: "/ws/terminals/term_1" }),
- ).toBe("wss://dev.example.test:15802/ws/terminals/term_1");
+ expect(buildDevWebSocketUrl({ path: "/ws/terminals/term_1" })).toBe(
+ "wss://dev.example.test:15802/ws/terminals/term_1",
+ );
});
it("returns undefined outside the dev build", () => {
diff --git a/apps/app/src/lib/dev-websocket-url.ts b/apps/app/src/lib/dev-websocket-url.ts
index 9101e33b9c..5ae6bae372 100644
--- a/apps/app/src/lib/dev-websocket-url.ts
+++ b/apps/app/src/lib/dev-websocket-url.ts
@@ -2,25 +2,35 @@ interface BuildDevWebSocketUrlArgs {
path: string;
}
-function resolveBrowserHostDevWebSocketBaseUrl(port: number): string {
+function resolveBrowserHostDevWebSocketBaseUrl(
+ serverPort: number,
+ appPort: number,
+): string {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
// HTTPS dev origins are typically reverse proxies or bb connect shares.
// Their public origin does not expose the backend's local TCP port, so keep
// the socket on the app origin and let Vite proxy /ws to the server.
- if (window.location.protocol === "https:") {
+ if (
+ window.location.protocol === "https:" ||
+ window.location.port !== String(appPort)
+ ) {
return `${protocol}//${window.location.host}/ws`;
}
// Direct sockets remain preferable for ordinary localhost/LAN source dev:
// they survive backend restarts more reliably than Vite's WS proxy.
- return `${protocol}//${window.location.hostname}:${port}/ws`;
+ return `${protocol}//${window.location.hostname}:${serverPort}/ws`;
}
function resolveDevWebSocketBaseUrl(): string | undefined {
- if (typeof __BB_DEV_WS_BROWSER_HOST_PORT__ === "number") {
+ if (
+ typeof __BB_DEV_WS_BROWSER_HOST_PORT__ === "number" &&
+ typeof __BB_DEV_APP_BROWSER_HOST_PORT__ === "number"
+ ) {
return resolveBrowserHostDevWebSocketBaseUrl(
__BB_DEV_WS_BROWSER_HOST_PORT__,
+ __BB_DEV_APP_BROWSER_HOST_PORT__,
);
}
diff --git a/apps/app/src/vite-env.d.ts b/apps/app/src/vite-env.d.ts
index a0ffd0ac7e..f7153037bc 100644
--- a/apps/app/src/vite-env.d.ts
+++ b/apps/app/src/vite-env.d.ts
@@ -2,3 +2,4 @@
/** Injected by vite.dev.config.ts to bypass Vite's WebSocket proxy. */
declare const __BB_DEV_WS_BROWSER_HOST_PORT__: number | undefined;
+declare const __BB_DEV_APP_BROWSER_HOST_PORT__: number | undefined;
diff --git a/apps/app/vite.dev.config.ts b/apps/app/vite.dev.config.ts
index 1d89e9766e..c616dc5f9a 100644
--- a/apps/app/vite.dev.config.ts
+++ b/apps/app/vite.dev.config.ts
@@ -13,6 +13,7 @@ export default defineConfig({
// Connect directly to the server in dev because Vite's WS proxy does not
// handle upstream server restarts reliably.
__BB_DEV_WS_BROWSER_HOST_PORT__: devWebSocketBrowserHostPortDefine,
+ __BB_DEV_APP_BROWSER_HOST_PORT__: JSON.stringify(viteDevConfig.appPort),
},
server: {
// Allow Tailscale MagicDNS names when Vite is behind Tailscale Serve.
diff --git a/apps/connect/.dev.vars.example b/apps/connect/.dev.vars.example
new file mode 100644
index 0000000000..c7fda7b491
--- /dev/null
+++ b/apps/connect/.dev.vars.example
@@ -0,0 +1,2 @@
+# Optional: only needed to exercise the local AI gateway.
+OPENAI_API_KEY=replace-with-openai-api-key
diff --git a/apps/connect/src/cloud-dev.test.ts b/apps/connect/src/cloud-dev.test.ts
new file mode 100644
index 0000000000..fc104d00e4
--- /dev/null
+++ b/apps/connect/src/cloud-dev.test.ts
@@ -0,0 +1,80 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ CLOUD_DEV_HOST_HEADER,
+ resolveConnectRequestHost,
+ resolveConnectRuntime,
+ waitForCloudService,
+} from "./cloud-dev.js";
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("local Cloud request routing", () => {
+ it("accepts the launcher host and selects HTTP cookies only in local Cloud", () => {
+ const runtime = resolveConnectRuntime({
+ ACCOUNT_APP_URL: "http://bb.localhost:8787",
+ BASE_DOMAIN: "bb.localhost",
+ CLOUD_DEV: "true",
+ });
+ const headers = new Headers({
+ host: "localhost",
+ [CLOUD_DEV_HOST_HEADER]: "sawyer--3000",
+ });
+ expect(resolveConnectRequestHost(headers, runtime)).toBe(
+ "sawyer--3000.bb.localhost",
+ );
+ expect(runtime.sessionCookieName).toBe("better-auth.session_token");
+ expect(runtime.desktopSessionCookieName).toBe("bb-connect.desktop_session");
+ });
+
+ it("ignores the launcher header in production", () => {
+ const runtime = resolveConnectRuntime({ BASE_DOMAIN: "getbb.app" });
+ const headers = new Headers({
+ host: "sawyer.getbb.app",
+ [CLOUD_DEV_HOST_HEADER]: "attacker",
+ });
+ expect(resolveConnectRequestHost(headers, runtime)).toBe(
+ "sawyer.getbb.app",
+ );
+ expect(runtime.sessionCookieName).toBe(
+ "__Secure-better-auth.session_token",
+ );
+ });
+
+ it("rejects deployed credential auth", () => {
+ expect(() =>
+ resolveConnectRuntime({
+ ACCOUNT_APP_URL: "https://getbb.app",
+ BASE_DOMAIN: "getbb.app",
+ CLOUD_DEV: "true",
+ }),
+ ).toThrow("only allowed for local Cloud development");
+ });
+
+ it("backs off after a 500 response and cancels its body", async () => {
+ vi.useFakeTimers();
+ const unavailable = new Response("starting", { status: 500 });
+ const cancel = vi.spyOn(unavailable.body!, "cancel");
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(unavailable)
+ .mockResolvedValueOnce(new Response(null, { status: 204 }));
+
+ const ready = waitForCloudService({
+ url: "http://127.0.0.1:42745/dashboard",
+ host: "bb.localhost:42745",
+ serviceExited: () => false,
+ timeoutMs: 1_000,
+ retryDelayMs: 250,
+ fetchImpl,
+ });
+ await vi.advanceTimersByTimeAsync(249);
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ await vi.advanceTimersByTimeAsync(1);
+ await ready;
+
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ expect(cancel).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/connect/src/cloud-dev.ts b/apps/connect/src/cloud-dev.ts
new file mode 100644
index 0000000000..66db9331f8
--- /dev/null
+++ b/apps/connect/src/cloud-dev.ts
@@ -0,0 +1,110 @@
+export const CLOUD_DEV_HOST_HEADER = "x-bb-cloud-dev-host";
+export const SECURE_SESSION_COOKIE = "__Secure-better-auth.session_token";
+export const LOCAL_SESSION_COOKIE = "better-auth.session_token";
+export const SECURE_DESKTOP_SESSION_COOKIE =
+ "__Secure-bb-connect.desktop_session";
+export const LOCAL_DESKTOP_SESSION_COOKIE = "bb-connect.desktop_session";
+
+export interface ConnectRuntime {
+ accountAppUrl: string;
+ baseDomain: string;
+ localCloud: boolean;
+ sessionCookieName: string;
+ desktopSessionCookieName: string;
+}
+
+/** Resolve the small, fail-closed set of overrides used by local Cloud. */
+export function resolveConnectRuntime(env: {
+ ACCOUNT_APP_URL?: string;
+ BASE_DOMAIN: string;
+ CLOUD_DEV?: string;
+}): ConnectRuntime {
+ const accountAppUrl = new URL(
+ env.ACCOUNT_APP_URL?.trim() || `https://${env.BASE_DOMAIN}`,
+ );
+ if (
+ (accountAppUrl.protocol !== "http:" &&
+ accountAppUrl.protocol !== "https:") ||
+ accountAppUrl.username !== "" ||
+ accountAppUrl.password !== "" ||
+ accountAppUrl.pathname !== "/" ||
+ accountAppUrl.search !== "" ||
+ accountAppUrl.hash !== ""
+ ) {
+ throw new Error("ACCOUNT_APP_URL must be an HTTP(S) origin");
+ }
+
+ const cloudDevValue = env.CLOUD_DEV?.trim();
+ if (cloudDevValue && cloudDevValue !== "true") {
+ throw new Error("CLOUD_DEV must be true when set");
+ }
+ const localCloud = cloudDevValue === "true";
+ if (localCloud) {
+ const isLocalAccount =
+ accountAppUrl.protocol === "http:" &&
+ accountAppUrl.hostname === env.BASE_DOMAIN &&
+ env.BASE_DOMAIN.endsWith(".localhost");
+ if (!isLocalAccount) {
+ throw new Error("CLOUD_DEV is only allowed for local Cloud development");
+ }
+ }
+
+ return {
+ accountAppUrl: accountAppUrl.origin,
+ baseDomain: env.BASE_DOMAIN,
+ localCloud,
+ sessionCookieName: localCloud
+ ? LOCAL_SESSION_COOKIE
+ : SECURE_SESSION_COOKIE,
+ desktopSessionCookieName: localCloud
+ ? LOCAL_DESKTOP_SESSION_COOKIE
+ : SECURE_DESKTOP_SESSION_COOKIE,
+ };
+}
+
+/** Wrangler replaces wildcard hosts locally; the launcher preserves the label. */
+export function resolveConnectRequestHost(
+ headers: Headers,
+ runtime: ConnectRuntime,
+): string {
+ const ordinaryHost = headers.get("host") ?? "";
+ if (!runtime.localCloud) return ordinaryHost;
+ const label = headers.get(CLOUD_DEV_HOST_HEADER)?.trim().toLowerCase();
+ if (!label || label.includes(".") || !/^[a-z0-9-]+$/u.test(label)) {
+ return ordinaryHost;
+ }
+ return `${label}.${runtime.baseDomain}`;
+}
+
+export function stripCloudDevHeader(headers: Headers): void {
+ headers.delete(CLOUD_DEV_HOST_HEADER);
+}
+
+export async function waitForCloudService(args: {
+ url: string;
+ host: string;
+ serviceExited: () => boolean;
+ timeoutMs?: number;
+ retryDelayMs?: number;
+ fetchImpl?: typeof fetch;
+}): Promise {
+ const deadline = Date.now() + (args.timeoutMs ?? 30_000);
+ const retryDelayMs = args.retryDelayMs ?? 250;
+ const fetchImpl = args.fetchImpl ?? fetch;
+ while (Date.now() < deadline) {
+ if (args.serviceExited()) throw new Error("Cloud service exited early");
+ try {
+ const response = await fetchImpl(args.url, {
+ headers: { host: args.host },
+ signal: AbortSignal.timeout(1_000),
+ });
+ const ready = response.status < 500;
+ await response.body?.cancel();
+ if (ready) return;
+ } catch {
+ // Retry transport failures until the shared startup deadline.
+ }
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
+ }
+ throw new Error(`timed out waiting for ${args.host}`);
+}
diff --git a/apps/connect/src/protocol-headers.ts b/apps/connect/src/protocol-headers.ts
new file mode 100644
index 0000000000..83fb7559b8
--- /dev/null
+++ b/apps/connect/src/protocol-headers.ts
@@ -0,0 +1,4 @@
+export const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target";
+export const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine";
+export const GATE_AUTH_HEADER = "x-bb-gate-auth";
+export const GATE_MACHINE_ID_HEADER = "x-bb-gate-machine-id";
diff --git a/apps/connect/src/servers.test.ts b/apps/connect/src/servers.test.ts
index 8112046dcb..bcb62bb469 100644
--- a/apps/connect/src/servers.test.ts
+++ b/apps/connect/src/servers.test.ts
@@ -304,6 +304,21 @@ describe("verifyServerCredential / resolveAccountUserId", () => {
},
});
expect(await resolveAccountUserId(req, secret, db)).toBe("acct-a");
+
+ const localRequest = new Request(
+ "http://sawyer.bb.localhost:8787/api/connect/servers",
+ {
+ headers: { cookie: `better-auth.session_token=${cookieValue}` },
+ },
+ );
+ expect(
+ await resolveAccountUserId(
+ localRequest,
+ secret,
+ db,
+ "better-auth.session_token",
+ ),
+ ).toBe("acct-a");
});
});
diff --git a/apps/connect/src/servers.ts b/apps/connect/src/servers.ts
index 3294bac531..8ee8f0be95 100644
--- a/apps/connect/src/servers.ts
+++ b/apps/connect/src/servers.ts
@@ -11,10 +11,14 @@ import {
verifyMachineCredential,
verifySessionCookie,
} from "./session.js";
+import {
+ resolveConnectRuntime,
+ SECURE_DESKTOP_SESSION_COOKIE,
+ SECURE_SESSION_COOKIE,
+} from "./cloud-dev.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_COOKIE = SECURE_DESKTOP_SESSION_COOKIE;
export const DESKTOP_SESSION_TTL_MS = 60 * 60 * 1000;
function bytesToBase64Url(bytes: Uint8Array): string {
@@ -167,6 +171,7 @@ export async function resolveAccountUserId(
request: Request,
secret: string,
db: ConnectDb,
+ sessionCookieName: string = SECURE_SESSION_COOKIE,
): Promise {
const presented = request.headers.get("x-bb-connect-machine") ?? "";
if (presented) {
@@ -179,7 +184,7 @@ export async function resolveAccountUserId(
if (serverUserId) return serverUserId;
}
- const cookie = parseCookie(request.headers.get("cookie"), SESSION_COOKIE);
+ const cookie = parseCookie(request.headers.get("cookie"), sessionCookieName);
if (!cookie) return null;
return verifySessionCookie(cookie, secret, db);
}
@@ -249,10 +254,12 @@ export async function handleListAccountServers(
}
const db = drizzle(env.DB, { schema });
+ const runtime = resolveConnectRuntime(env);
const userId = await resolveAccountUserId(
request,
env.BETTER_AUTH_SECRET,
db,
+ runtime.sessionCookieName,
);
if (!userId) {
return new Response(JSON.stringify({ error: "unauthorized" }), {
@@ -283,10 +290,12 @@ export async function handleCreateDesktopSession(
});
}
const db = drizzle(env.DB, { schema });
+ const runtime = resolveConnectRuntime(env);
const userId = await resolveAccountUserId(
request,
env.BETTER_AUTH_SECRET,
db,
+ runtime.sessionCookieName,
);
if (!userId) {
return new Response(JSON.stringify({ error: "unauthorized" }), {
@@ -305,7 +314,7 @@ export async function handleCreateDesktopSession(
cookie: {
domain: `.${env.BASE_DOMAIN}`,
expiresAt,
- name: DESKTOP_SESSION_COOKIE,
+ name: runtime.desktopSessionCookieName,
value,
},
}),
diff --git a/apps/connect/src/tunnel-do.ts b/apps/connect/src/tunnel-do.ts
index c65df2953f..7eca8777af 100644
--- a/apps/connect/src/tunnel-do.ts
+++ b/apps/connect/src/tunnel-do.ts
@@ -11,12 +11,15 @@ import {
type HeaderPair,
} from "@bb/tunnel-contract";
import { relayedResponse } from "./response-encoding.js";
+import { TUNNEL_TARGET_HEADER } from "./protocol-headers.js";
export interface Env {
TUNNEL_DO: DurableObjectNamespace;
DB: D1Database;
BASE_DOMAIN: string;
BETTER_AUTH_SECRET: string;
+ ACCOUNT_APP_URL?: string;
+ CLOUD_DEV?: string;
}
const TUNNEL_TAG = "tunnel";
@@ -26,9 +29,6 @@ const RESP_HEAD_TIMEOUT_MS = 30_000;
// run JS), kept under the 90s offline window.
const PRESENCE_INTERVAL_MS = 50_000;
-/** Gate → DO header carrying a share target; never forwarded to the origin. */
-const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target";
-
// Standard WebSocket readyState numbering (workerd's READY_STATE_OPEN; the
// constant itself is Cloudflare-only, so tests in Node use the number).
const WS_READY_STATE_OPEN = 1;
diff --git a/apps/connect/src/worker.test.ts b/apps/connect/src/worker.test.ts
index c69c7aa246..45a679d304 100644
--- a/apps/connect/src/worker.test.ts
+++ b/apps/connect/src/worker.test.ts
@@ -5,13 +5,15 @@ import { machine } from "@bb/connect-db";
import { cacheKey } from "./cache";
import { parseClientProtocolVersion } from "./tunnel-do";
import {
- GATE_AUTH_HEADER,
- GATE_MACHINE_ID_HEADER,
- TUNNEL_TARGET_HEADER,
cacheNamespace,
dashboardSignInUrl,
requestForTunnelDo,
} from "./worker";
+import {
+ GATE_AUTH_HEADER,
+ GATE_MACHINE_ID_HEADER,
+ TUNNEL_TARGET_HEADER,
+} from "./protocol-headers";
// ── pure helpers ────────────────────────────────────────────────────────────
diff --git a/apps/connect/src/worker.ts b/apps/connect/src/worker.ts
index 5776891e5a..5feda05640 100644
--- a/apps/connect/src/worker.ts
+++ b/apps/connect/src/worker.ts
@@ -9,7 +9,6 @@ import {
verifySessionCookie,
} from "./session.js";
import {
- DESKTOP_SESSION_COOKIE,
handleCreateDesktopSession,
handleListAccountServers,
verifyDesktopSessionCookie,
@@ -17,17 +16,20 @@ import {
import { serveWithCache } from "./cache.js";
import { BB_ICON_DATA_URI } from "./bb-icon.js";
import { handleAssignMachineLabel } from "./machine-label.js";
+import {
+ resolveConnectRequestHost,
+ resolveConnectRuntime,
+ stripCloudDevHeader,
+} from "./cloud-dev.js";
+import {
+ GATE_AUTH_HEADER,
+ GATE_MACHINE_ID_HEADER,
+ MACHINE_CREDENTIAL_HEADER,
+ TUNNEL_TARGET_HEADER,
+} from "./protocol-headers.js";
export { TunnelDO };
-const SESSION_COOKIE = "__Secure-better-auth.session_token";
-
-/** Internal header: gate → TunnelDO, share target (port string). Never trust visitors. */
-export const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target";
-export const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine";
-export const GATE_AUTH_HEADER = "x-bb-gate-auth";
-export const GATE_MACHINE_ID_HEADER = "x-bb-gate-machine-id";
-
async function sha256Hex(value: string): Promise {
const digest = await crypto.subtle.digest(
"SHA-256",
@@ -223,6 +225,7 @@ export function requestForTunnelDo(
headers.delete(MACHINE_CREDENTIAL_HEADER);
headers.delete(GATE_AUTH_HEADER);
headers.delete(GATE_MACHINE_ID_HEADER);
+ stripCloudDevHeader(headers);
if (target !== null) {
headers.set(TUNNEL_TARGET_HEADER, target);
}
@@ -266,6 +269,7 @@ export default {
ctx: ExecutionContext,
): Promise {
const url = new URL(request.url);
+ const runtime = resolveConnectRuntime(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.
@@ -279,7 +283,7 @@ export default {
return handleAssignMachineLabel(request, env);
}
- const host = request.headers.get("host") ?? url.host;
+ const host = resolveConnectRequestHost(request.headers, runtime);
const parsed = parseVisitorHost(host, env.BASE_DOMAIN);
if (!parsed) return text("bb connect: unknown host\n", 404);
// The base label is now ANY server's subdomain (the account handle names the
@@ -291,7 +295,7 @@ export default {
// rather than answering with a confusing "no server" page.
if (RESERVED_HANDLES.has(label)) {
return Response.redirect(
- `https://${env.BASE_DOMAIN}${url.pathname}${url.search}`,
+ `${runtime.accountAppUrl}${url.pathname}${url.search}`,
301,
);
}
@@ -342,7 +346,11 @@ export default {
} else {
forward.searchParams.set("machineId", owner.id);
}
- return stub.fetch(new Request(forward, request));
+ const headers = new Headers(request.headers);
+ stripCloudDevHeader(headers);
+ return stub.fetch(
+ new Request(new Request(forward, request), { headers }),
+ );
}
// Reserve the /__ namespace: never proxy internal paths from outside.
@@ -368,6 +376,7 @@ export default {
headers.delete(TUNNEL_TARGET_HEADER);
headers.delete(GATE_AUTH_HEADER);
headers.delete(GATE_MACHINE_ID_HEADER);
+ stripCloudDevHeader(headers);
return stub.fetch(new Request(request, { headers }));
}
@@ -401,6 +410,7 @@ export default {
headers.delete(TUNNEL_TARGET_HEADER);
headers.delete(GATE_AUTH_HEADER);
headers.delete(GATE_MACHINE_ID_HEADER);
+ stripCloudDevHeader(headers);
headers.set(GATE_AUTH_HEADER, "machine");
headers.set(GATE_MACHINE_ID_HEADER, verified.machineId);
return stub.fetch(new Request(request, { headers }));
@@ -413,9 +423,12 @@ 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 desktopCookie = parseCookie(cookieHeader, DESKTOP_SESSION_COOKIE);
- const appUrl = `https://${env.BASE_DOMAIN}`;
+ const cookie = parseCookie(cookieHeader, runtime.sessionCookieName);
+ const desktopCookie = parseCookie(
+ cookieHeader,
+ runtime.desktopSessionCookieName,
+ );
+ const appUrl = runtime.accountAppUrl;
if (!cookie && !desktopCookie)
return signInPage(label, appUrl, url.toString());
const sessionUserId = cookie
diff --git a/apps/host-daemon/src/connect-tunnel/connect-tunnel.test.ts b/apps/host-daemon/src/connect-tunnel/connect-tunnel.test.ts
index 35db6bd6e1..a86f6c010e 100644
--- a/apps/host-daemon/src/connect-tunnel/connect-tunnel.test.ts
+++ b/apps/host-daemon/src/connect-tunnel/connect-tunnel.test.ts
@@ -11,7 +11,9 @@ import { WebSocket, WebSocketServer, type RawData } from "ws";
import type { HostDaemonConnectTunnelIdentity } from "@bb/host-daemon-contract";
import type { HostDaemonLogger } from "../logger.js";
import {
+ buildMachineTunnelUrl,
ConnectTunnelClient,
+ resolveTrustedConnectGate,
type ConnectTunnelFetch,
type ConnectTunnelStatus,
type CreateTunnelWebSocket,
@@ -117,6 +119,26 @@ afterEach(async () => {
});
describe("ConnectTunnelClient", () => {
+ it("allows HTTP only for a local machine gate and derives ws URLs", () => {
+ expect(
+ resolveTrustedConnectGate("http://owner.bb.localhost:42745"),
+ ).toEqual({
+ apiOrigin: "http://owner.bb.localhost:42745",
+ baseDomain: "bb.localhost:42745",
+ protocol: "http:",
+ });
+ expect(
+ buildMachineTunnelUrl({
+ label: "sawyer-air",
+ baseDomain: "bb.localhost:42745",
+ protocol: "http:",
+ }),
+ ).toBe("ws://sawyer-air.bb.localhost:42745/__tunnel?v=1");
+ expect(() => resolveTrustedConnectGate("http://owner.getbb.app")).toThrow(
+ "HTTPS or a local *.localhost",
+ );
+ });
+
it("assigns its own label at the enrolled gate, dials on first share, and closes on the last", async () => {
const gateServer = createServer();
const gatePort = await listen(gateServer);
@@ -152,7 +174,11 @@ describe("ConnectTunnelClient", () => {
},
]);
expect(identities).toEqual([
- { label: "sawyer-air", baseDomain: "getbb.app" },
+ {
+ label: "sawyer-air",
+ baseDomain: "getbb.app",
+ protocol: "https:",
+ },
]);
// The share declaration supplied only a port. The credential destination
// is derived exclusively from the daemon's enrollment server.
diff --git a/apps/host-daemon/src/connect-tunnel/index.ts b/apps/host-daemon/src/connect-tunnel/index.ts
index 2b82b9b4b4..095d716d09 100644
--- a/apps/host-daemon/src/connect-tunnel/index.ts
+++ b/apps/host-daemon/src/connect-tunnel/index.ts
@@ -52,6 +52,7 @@ export interface ConnectTunnelClientOptions {
interface TrustedConnectGate {
apiOrigin: string;
baseDomain: string;
+ protocol: "http:" | "https:";
}
export class ConnectTunnelCredentialRejectedError extends Error {
@@ -67,9 +68,11 @@ export function resolveTrustedConnectGate(
serverUrl: string,
): TrustedConnectGate {
const parsed = new URL(serverUrl);
- if (parsed.protocol !== "https:") {
+ const localHttp =
+ parsed.protocol === "http:" && parsed.hostname.endsWith(".localhost");
+ if (parsed.protocol !== "https:" && !localHttp) {
throw new Error(
- `bb connect machine credentials require an HTTPS enrollment server, got ${parsed.origin}`,
+ `bb connect machine credentials require HTTPS or a local *.localhost enrollment server, got ${parsed.origin}`,
);
}
const firstDot = parsed.hostname.indexOf(".");
@@ -82,14 +85,16 @@ export function resolveTrustedConnectGate(
return {
apiOrigin: parsed.origin,
baseDomain: `${baseHostname}${parsed.port ? `:${parsed.port}` : ""}`,
+ protocol: localHttp ? "http:" : "https:",
};
}
export function buildMachineTunnelUrl(
identity: HostDaemonConnectTunnelIdentity,
): string {
+ const websocketProtocol = identity.protocol === "https:" ? "wss:" : "ws:";
const url = new URL(
- `wss://${identity.label}.${identity.baseDomain}/__tunnel`,
+ `${websocketProtocol}//${identity.label}.${identity.baseDomain}/__tunnel`,
);
url.searchParams.set(TUNNEL_PROTOCOL_QUERY_PARAM, String(PROTOCOL_VERSION));
return url.toString();
@@ -222,6 +227,7 @@ export class ConnectTunnelClient {
const identity = hostDaemonConnectTunnelIdentitySchema.parse({
label: body.label,
baseDomain: gate.baseDomain,
+ protocol: gate.protocol,
});
this.identity = identity;
this.options.onIdentity?.(identity);
@@ -450,7 +456,7 @@ export class ConnectTunnelClient {
kind: "ok",
resolved: {
origin: `http://127.0.0.1:${port}`,
- publicOrigin: `https://${identity.label}--${port}.${identity.baseDomain}`,
+ publicOrigin: `${identity.protocol}//${identity.label}--${port}.${identity.baseDomain}`,
host: `127.0.0.1:${port}`,
},
};
diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts
index f6e28a1f24..fc31f31895 100644
--- a/apps/server/src/services/plugins/plugin-api.ts
+++ b/apps/server/src/services/plugins/plugin-api.ts
@@ -1248,9 +1248,10 @@ export function createPluginApi(options: {
};
const hosts: PluginHosts = {
- ensureSharedPortTunnel(hostId) {
+ async ensureSharedPortTunnel(hostId) {
assertLive();
- return ensureSharedPortTunnel(hostId);
+ const identity = await ensureSharedPortTunnel(hostId);
+ return { label: identity.label, baseDomain: identity.baseDomain };
},
declareSharedPorts(hostId, ports) {
assertLive();
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 cdc754a913..93a043b1e5 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
@@ -180,6 +180,10 @@ isolated|reuse`, or anchor with `--source-seq-end`. Permission mode inherits
https://getbb.app). Pairing returns immediately — the
server itself holds the tunnel and reconnects on restart, so there is no
foreground process.
+ In a source checkout, `pnpm dev` automatically sets
+ `BB_DEV_CONNECT_BASE_URL` to the worktree's local Cloud origin. Connect uses
+ it only as the unpaired default; explicit `--server` and `--base-url` values
+ still win, including when pairing the dev bb with getbb.app.
`bb connect status` / `bb connect off` report and clear the pairing.
Port sharing works from a thread on any enrolled host. `bb connect expose
` resolves that thread's environment host and returns its public URL;
diff --git a/apps/server/test/app/host-shared-ports.test.ts b/apps/server/test/app/host-shared-ports.test.ts
index 2486907ee1..915cdaa1cc 100644
--- a/apps/server/test/app/host-shared-ports.test.ts
+++ b/apps/server/test/app/host-shared-ports.test.ts
@@ -253,11 +253,17 @@ describe("HostSharedPortCoordinator", () => {
sharedPorts.recordTunnelIdentity(host.id, {
label: "sawyer-air",
baseDomain: "getbb.app",
+ protocol: "https:",
}),
- ).toEqual({ label: "sawyer-air", baseDomain: "getbb.app" });
+ ).toEqual({
+ label: "sawyer-air",
+ baseDomain: "getbb.app",
+ protocol: "https:",
+ });
expect(sharedPorts.getTunnelIdentity(host.id)).toEqual({
label: "sawyer-air",
baseDomain: "getbb.app",
+ protocol: "https:",
});
});
});
@@ -377,12 +383,17 @@ describe("daemon session connect shares", () => {
socket: daemonSocket,
raw: JSON.stringify({
type: "connect-tunnel.identity",
- identity: { label: "sawyer-air", baseDomain: "getbb.app" },
+ identity: {
+ label: "sawyer-air",
+ baseDomain: "getbb.app",
+ protocol: "https:",
+ },
}),
});
expect(harness.deps.sharedPorts.getTunnelIdentity("host-1")).toEqual({
label: "sawyer-air",
baseDomain: "getbb.app",
+ protocol: "https:",
});
});
});
diff --git a/apps/server/test/services/plugins/plugin-sdk.test.ts b/apps/server/test/services/plugins/plugin-sdk.test.ts
index 3c8d6a2188..782337ca93 100644
--- a/apps/server/test/services/plugins/plugin-sdk.test.ts
+++ b/apps/server/test/services/plugins/plugin-sdk.test.ts
@@ -71,6 +71,7 @@ describe("plugin bb.sdk bind gate", () => {
const ensureSharedPortTunnel = vi.fn().mockResolvedValue({
label: "sawyer-air",
baseDomain: "getbb.app",
+ protocol: "https:",
});
beforeEach(async () => {
diff --git a/apps/web/src/lib/connect-return-to.test.ts b/apps/web/src/lib/connect-return-to.test.ts
index c8a8cef59a..a44ced11af 100644
--- a/apps/web/src/lib/connect-return-to.test.ts
+++ b/apps/web/src/lib/connect-return-to.test.ts
@@ -21,6 +21,15 @@ describe("connect return-to URLs", () => {
).toBe("https://sawyer.vibecodethis.site/");
});
+ it("accepts local Cloud handles under the shared cookie domain", () => {
+ expect(
+ connectReturnTo(
+ "http://sawyer.bb.localhost:42745/threads/thr_1",
+ "http://bb.localhost:42745",
+ ),
+ ).toBe("http://sawyer.bb.localhost:42745/threads/thr_1");
+ });
+
it("rejects nested subdomains and off-domain return targets", () => {
expect(
connectReturnTo("https://a.b.getbb.app/", "https://getbb.app"),
diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx
index ce966c5347..1da61a492e 100644
--- a/apps/web/src/routes/dashboard.tsx
+++ b/apps/web/src/routes/dashboard.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useState, type FormEvent } from "react";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { HugeiconsIcon } from "@hugeicons/react";
import {
@@ -11,6 +11,8 @@ import { MAX_SERVERS_PER_ACCOUNT } from "@bb/connect-db";
import type { HandleValidationError, LabelAvailability } from "@bb/connect-db";
import appCss from "../styles.css?url";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import {
checkAvailabilityFn,
@@ -304,6 +306,34 @@ async function signInWithGithub(returnTo: string | undefined) {
if (data.url) window.location.href = data.url;
}
+type EmailAuthMode = "sign-in" | "sign-up";
+
+function authResponseMessage(value: unknown): string | null {
+ if (typeof value !== "object" || value === null) return null;
+ if (!("message" in value) || typeof value.message !== "string") return null;
+ return value.message;
+}
+
+async function authenticateWithEmail(input: {
+ email: string;
+ mode: EmailAuthMode;
+ name: string;
+ password: string;
+}): Promise {
+ const body =
+ input.mode === "sign-up"
+ ? { email: input.email, name: input.name, password: input.password }
+ : { email: input.email, password: input.password };
+ const response = await fetch(`/api/auth/${input.mode}/email`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const responseBody: unknown = await response.json().catch(() => null);
+ if (response.ok) return null;
+ return authResponseMessage(responseBody) ?? "Could not authenticate";
+}
+
async function signOut() {
// better-auth requires the JSON content-type (else 415) and a JSON body
// (an empty body makes it 500); the browser supplies the Origin it checks.
@@ -327,14 +357,63 @@ function Home() {
if (returnTo) window.location.assign(returnTo);
}, [data.authed, search.returnTo]);
- if (!data.authed) return ;
- if (!data.handle) return ;
+ if (!data.authed)
+ return (
+
+ );
+ if (!data.handle)
+ return ;
return ;
}
/* ── W1: sign in ──────────────────────────────────────────────────── */
-function SignInView({ returnTo }: { returnTo: string | undefined }) {
+function SignInView({
+ emailPasswordEnabled,
+ returnTo,
+}: {
+ emailPasswordEnabled: boolean;
+ returnTo: string | undefined;
+}) {
+ const [mode, setMode] = useState("sign-in");
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const submitEmail = async (event: FormEvent) => {
+ event.preventDefault();
+ const trimmedName = name.trim();
+ if (mode === "sign-up" && !trimmedName) {
+ setError("Enter your name");
+ return;
+ }
+ setSubmitting(true);
+ setError(null);
+ try {
+ const authError = await authenticateWithEmail({
+ email: email.trim(),
+ mode,
+ name: trimmedName,
+ password,
+ });
+ if (authError) {
+ setError(authError);
+ return;
+ }
+ window.location.href =
+ connectReturnTo(returnTo, window.location.origin) ?? DASHBOARD_PATH;
+ } catch {
+ setError("Could not reach the authentication service");
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
return (
@@ -343,8 +422,95 @@ function SignInView({ returnTo }: { returnTo: string | undefined }) {
Give your bb a private URL and open it from any browser. Your code and
data never leave your machine.
+ {emailPasswordEnabled ? (
+ <>
+
+
+ {mode === "sign-in"
+ ? "New to this local Cloud?"
+ : "Already registered?"}{" "}
+
+
+
+
+ or
+
+
+ >
+ ) : null}
{state.servers.map((s: ServerSummary) => (
-
+
))}
@@ -1115,10 +1274,9 @@ function AccountDashboard({ state }: { state: ServerState }) {
{machine.subdomain !== null ? (
- {machine.subdomain}
-
- .{state.baseDomain}
-
+ {state.serverUrlTemplate
+ .replace("{label}", machine.subdomain)
+ .replace(/^https?:\/\//u, "")}
) : (
diff --git a/apps/web/src/server/api.test.ts b/apps/web/src/server/api.test.ts
index 0e0a8c6ca8..986e926810 100644
--- a/apps/web/src/server/api.test.ts
+++ b/apps/web/src/server/api.test.ts
@@ -26,6 +26,7 @@ import {
getAccountState,
redeemConnectCode,
redeemMachineCode,
+ resolveServerUrlTemplate,
revokeMachineForServerCredential,
revokeMachine,
} from "./api.js";
@@ -55,10 +56,28 @@ beforeEach(() => {
db,
baseDomain: "getbb.app",
appUrl: "https://getbb.app",
+ serverUrlTemplate: "https://{label}.getbb.app",
closeTunnel,
};
});
+describe("resolveServerUrlTemplate", () => {
+ it("accepts the local HTTP port without changing production defaults", () => {
+ expect(resolveServerUrlTemplate(undefined, "getbb.app")).toBe(
+ "https://{label}.getbb.app",
+ );
+ expect(
+ resolveServerUrlTemplate(
+ "http://{label}.bb.localhost:8787",
+ "bb.localhost",
+ ),
+ ).toBe("http://{label}.bb.localhost:8787");
+ expect(() =>
+ resolveServerUrlTemplate("https://example.com/{label}", "example.com"),
+ ).toThrow("under BASE_DOMAIN");
+ });
+});
+
afterEach(() => {
sqlite.close();
});
diff --git a/apps/web/src/server/api.ts b/apps/web/src/server/api.ts
index abdcd3ea6d..2a64fab7c3 100644
--- a/apps/web/src/server/api.ts
+++ b/apps/web/src/server/api.ts
@@ -28,14 +28,49 @@ export interface Deps {
db: ConnectDb;
baseDomain: string;
appUrl: string;
+ serverUrlTemplate: string;
closeTunnel?: (routingKey: string) => Promise;
}
+export function resolveServerUrlTemplate(
+ value: string | undefined,
+ baseDomain: string,
+): string {
+ const template = value?.trim() || `https://{label}.${baseDomain}`;
+ if (template.split("{label}").length !== 2) {
+ throw new Error("CONNECT_SERVER_URL_TEMPLATE must contain {label} once");
+ }
+ const probe = "bb-label-probe";
+ const url = new URL(template.replace("{label}", probe));
+ if (
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
+ url.username !== "" ||
+ url.password !== "" ||
+ url.pathname !== "/" ||
+ url.search !== "" ||
+ url.hash !== "" ||
+ url.hostname !== `${probe}.${baseDomain}`
+ ) {
+ throw new Error(
+ "CONNECT_SERVER_URL_TEMPLATE must be an HTTP(S) origin under BASE_DOMAIN",
+ );
+ }
+ return `${url.protocol}//{label}.${baseDomain}${url.port ? `:${url.port}` : ""}`;
+}
+
+function serverUrlForLabel(label: string, template: string): string {
+ return template.replace("{label}", label);
+}
+
export function depsFromEnv(env: Env): Deps {
return {
db: drizzle(env.DB),
baseDomain: env.BASE_DOMAIN,
appUrl: env.APP_URL,
+ serverUrlTemplate: resolveServerUrlTemplate(
+ env.CONNECT_SERVER_URL_TEMPLATE,
+ env.BASE_DOMAIN,
+ ),
closeTunnel: async (routingKey) => {
const stub = env.TUNNEL_DO.get(env.TUNNEL_DO.idFromName(routingKey));
const response = await stub.fetch("https://tunnel/__control/close");
@@ -80,6 +115,7 @@ export interface AccountState {
servers: ServerSummary[];
appUrl: string;
baseDomain: string;
+ serverUrlTemplate: string;
/** GitHub login for the account footer link; null for pre-column rows. */
githubLogin: string | null;
/** Per-account server ceiling, surfaced in the footer as "N of MAX bbs". */
@@ -103,7 +139,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 +157,7 @@ function toServerSummary(
lastSeenAt: lastSeenMs,
version: srv.version,
createdAt: srv.createdAt.getTime(),
- serverUrl: `https://${srv.subdomain}.${baseDomain}`,
+ serverUrl: serverUrlForLabel(srv.subdomain, serverUrlTemplate),
};
}
@@ -165,7 +201,7 @@ export async function getAccountState(
deps: Deps,
userId: string,
): Promise {
- const { db, baseDomain } = deps;
+ const { db, baseDomain, serverUrlTemplate } = deps;
await retryPendingMachineRevocations(deps, userId);
const prof = await db
.select()
@@ -182,6 +218,7 @@ export async function getAccountState(
const base = {
appUrl: deps.appUrl,
baseDomain,
+ serverUrlTemplate,
githubLogin: userRow?.githubLogin ?? null,
maxServers: MAX_SERVERS_PER_ACCOUNT,
};
@@ -204,7 +241,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 +260,7 @@ export async function getAccountState(
.all();
const servers = serverRows
- .map((srv) => toServerSummary(srv, prof.handle, baseDomain, now))
+ .map((srv) => toServerSummary(srv, prof.handle, serverUrlTemplate, now))
.sort((a, b) =>
a.isPrimary !== b.isPrimary
? a.isPrimary
@@ -385,7 +423,7 @@ export async function createServer(
userId: string,
rawLabel: string,
): Promise<{ ok: true; server: ServerSummary } | { error: CreateServerError }> {
- const { db, baseDomain } = deps;
+ const { db, serverUrlTemplate } = deps;
const prof = await db
.select()
.from(profile)
@@ -436,7 +474,12 @@ export async function createServer(
}
return {
ok: true,
- server: toServerSummary(created, prof.handle, baseDomain, Date.now()),
+ server: toServerSummary(
+ created,
+ prof.handle,
+ serverUrlTemplate,
+ Date.now(),
+ ),
};
}
@@ -457,10 +500,10 @@ export async function createConnectCode(
userId: string,
opts: { serverId?: string; reuse?: boolean } = {},
): Promise {
- const { db, baseDomain } = deps;
+ const { db, serverUrlTemplate } = deps;
const srv = await resolveServer(db, userId, opts.serverId);
if (!srv) return { error: "no-server" };
- const serverUrl = `https://${srv.subdomain}.${baseDomain}`;
+ const serverUrl = serverUrlForLabel(srv.subdomain, serverUrlTemplate);
const now = Date.now();
if (opts.reuse) {
@@ -517,7 +560,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 +602,7 @@ export async function createMachineCode(
return {
code,
expiresInMs: CONNECT_CODE_TTL_MS,
- serverUrl: `https://${srv.subdomain}.${baseDomain}`,
+ serverUrl: serverUrlForLabel(srv.subdomain, serverUrlTemplate),
};
}
@@ -717,7 +760,7 @@ function rowsChanged(result: unknown): number {
* Accepts `Deps` (D1 in the worker via `depsFromEnv`, better-sqlite3 in tests).
*/
export async function redeemConnectCode(
- deps: Pick,
+ deps: Pick,
code: string,
): Promise<
| {
@@ -728,7 +771,7 @@ export async function redeemConnectCode(
}
| { error: string; status: number }
> {
- const { db, baseDomain } = deps;
+ const { db, serverUrlTemplate } = deps;
const normalized = code.trim().toUpperCase();
if (!normalized) return { error: "missing-code", status: 400 };
@@ -765,19 +808,19 @@ export async function redeemConnectCode(
.from(server)
.where(eq(server.id, row.serverId))
.get();
- const prof = await db
- .select()
- .from(profile)
- .where(eq(profile.userId, row.userId))
- .get();
// Routing label of the redeemed server (not necessarily the account handle).
- const handle = srv?.subdomain ?? prof?.handle ?? null;
+ const handle = srv?.subdomain ?? null;
+ const serverUrl = handle
+ ? serverUrlForLabel(handle, serverUrlTemplate)
+ : null;
return {
credential,
serverId: row.serverId,
handle,
// Keyed by this server's subdomain (which may be non-primary), not the account handle.
- tunnelUrl: srv ? `wss://${srv.subdomain}.${baseDomain}/__tunnel` : null,
+ tunnelUrl: serverUrl
+ ? `${serverUrl.replace(/^http/u, "ws")}/__tunnel`
+ : null,
};
}
@@ -786,7 +829,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 +840,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 +909,6 @@ export async function redeemMachineCode(
credential,
machineId,
handle: prof?.handle ?? null,
- serverUrl: label ? `https://${label}.${baseDomain}` : null,
+ serverUrl: label ? serverUrlForLabel(label, serverUrlTemplate) : null,
};
}
diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts
index 0b417223ff..2097915a52 100644
--- a/apps/web/src/server/auth.ts
+++ b/apps/web/src/server/auth.ts
@@ -3,21 +3,28 @@ import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { drizzle } from "drizzle-orm/d1";
import { account, session, user, verification } from "@bb/connect-db";
import type { Env } from "./env.js";
+import { resolveDevEmailPasswordEnabled } from "./local-auth.js";
export type Auth = ReturnType;
/**
- * better-auth bound to the staging D1 via drizzle. GitHub is the only provider.
+ * better-auth bound to the Cloud D1 via drizzle. Production uses GitHub;
+ * local Cloud additionally enables email/password credentials.
* Cookies are scoped to `.${BASE_DOMAIN}` so the tunnel gate on
* `.${BASE_DOMAIN}` can validate the same session.
*/
export function createAuth(env: Env) {
const db = drizzle(env.DB);
+ const appUrl = new URL(env.APP_URL);
+ const devEmailPasswordEnabled = resolveDevEmailPasswordEnabled(env);
+ const subdomainOrigin = `${appUrl.protocol}//*.${env.BASE_DOMAIN}${
+ appUrl.port ? `:${appUrl.port}` : ""
+ }`;
return betterAuth({
appName: "bb connect",
secret: env.BETTER_AUTH_SECRET,
baseURL: env.APP_URL,
- trustedOrigins: [env.APP_URL, `https://*.${env.BASE_DOMAIN}`],
+ trustedOrigins: [env.APP_URL, subdomainOrigin],
// `better-auth` and `@better-auth/drizzle-adapter` resolve to two copies of
// `@better-auth/core` under pnpm (different peer hashes — workers-types is in
// one peer set), so the adapter's type is nominally distinct though identical
@@ -26,7 +33,7 @@ export function createAuth(env: Env) {
provider: "sqlite",
schema: { user, session, account, verification },
}) as unknown as Parameters[0]["database"],
- emailAndPassword: { enabled: false },
+ emailAndPassword: { enabled: devEmailPasswordEnabled },
user: {
additionalFields: {
githubLogin: { type: "string", required: false, input: false },
diff --git a/apps/web/src/server/current-user.server.ts b/apps/web/src/server/current-user.server.ts
index ad1bf45da3..a9c8b643d5 100644
--- a/apps/web/src/server/current-user.server.ts
+++ b/apps/web/src/server/current-user.server.ts
@@ -7,8 +7,9 @@ import { getEnv } from "./env.js";
/** The authenticated user id for the current request, or null. */
export async function getSessionUserId(): Promise {
+ const env = getEnv();
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..83ad25310d 100644
--- a/apps/web/src/server/env.ts
+++ b/apps/web/src/server/env.ts
@@ -5,6 +5,8 @@ export interface Env {
TUNNEL_DO: DurableObjectNamespace;
BASE_DOMAIN: string;
APP_URL: string;
+ CONNECT_SERVER_URL_TEMPLATE?: string;
+ DEV_EMAIL_PASSWORD_AUTH?: string;
GITHUB_CLIENT_ID: string;
GITHUB_CLIENT_SECRET: string;
BETTER_AUTH_SECRET: string;
diff --git a/apps/web/src/server/fns.ts b/apps/web/src/server/fns.ts
index 4a50dec7d3..eecb41a3f0 100644
--- a/apps/web/src/server/fns.ts
+++ b/apps/web/src/server/fns.ts
@@ -14,22 +14,29 @@ import {
} from "./api.js";
import { getEnv } from "./env.js";
import { getSessionUserId } from "./current-user.server.js";
+import { resolveDevEmailPasswordEnabled } from "./local-auth.js";
// The ONLY server module the client route imports. Everything here is a
// createServerFn, so the client receives RPC stubs and none of the server-only
// imports (D1, better-auth, cloudflare:workers) land in the client bundle.
export type DashboardState =
- | { authed: false }
+ | { authed: false; emailPasswordEnabled: boolean }
| ({ authed: true } & AccountState);
export const getDashboard = createServerFn({ method: "GET" }).handler(
async (): Promise => {
+ const env = getEnv();
const userId = await getSessionUserId();
- if (!userId) return { authed: false };
+ if (!userId) {
+ return {
+ authed: false,
+ emailPasswordEnabled: resolveDevEmailPasswordEnabled(env),
+ };
+ }
return {
authed: true,
- ...(await getAccountState(depsFromEnv(getEnv()), userId)),
+ ...(await getAccountState(depsFromEnv(env), userId)),
};
},
);
diff --git a/apps/web/src/server/local-auth.test.ts b/apps/web/src/server/local-auth.test.ts
new file mode 100644
index 0000000000..d51142fbfa
--- /dev/null
+++ b/apps/web/src/server/local-auth.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { resolveDevEmailPasswordEnabled } from "./local-auth.js";
+
+describe("resolveDevEmailPasswordEnabled", () => {
+ it("enables credential auth only on the local Cloud origin", () => {
+ expect(
+ resolveDevEmailPasswordEnabled({
+ APP_URL: "http://bb.localhost:8787",
+ BASE_DOMAIN: "bb.localhost",
+ DEV_EMAIL_PASSWORD_AUTH: "true",
+ }),
+ ).toBe(true);
+
+ expect(
+ resolveDevEmailPasswordEnabled({
+ APP_URL: "https://getbb.app",
+ BASE_DOMAIN: "getbb.app",
+ }),
+ ).toBe(false);
+
+ expect(() =>
+ resolveDevEmailPasswordEnabled({
+ APP_URL: "https://getbb.app",
+ BASE_DOMAIN: "getbb.app",
+ DEV_EMAIL_PASSWORD_AUTH: "true",
+ }),
+ ).toThrow("only allowed for local Cloud development");
+ });
+
+ it("rejects ambiguous flag values", () => {
+ expect(() =>
+ resolveDevEmailPasswordEnabled({
+ APP_URL: "http://bb.localhost:8787",
+ BASE_DOMAIN: "bb.localhost",
+ DEV_EMAIL_PASSWORD_AUTH: "1",
+ }),
+ ).toThrow("must be true when set");
+ });
+});
diff --git a/apps/web/src/server/local-auth.ts b/apps/web/src/server/local-auth.ts
new file mode 100644
index 0000000000..33b597642a
--- /dev/null
+++ b/apps/web/src/server/local-auth.ts
@@ -0,0 +1,29 @@
+import type { Env } from "./env.js";
+
+/** Enable credential auth only for the launcher's local HTTP origin. */
+export function resolveDevEmailPasswordEnabled(
+ env: Pick,
+): boolean {
+ const value = env.DEV_EMAIL_PASSWORD_AUTH?.trim();
+ if (!value) return false;
+ if (value !== "true") {
+ throw new Error("DEV_EMAIL_PASSWORD_AUTH must be true when set");
+ }
+
+ const appUrl = new URL(env.APP_URL);
+ const isLocalOrigin =
+ appUrl.protocol === "http:" &&
+ appUrl.hostname === env.BASE_DOMAIN &&
+ env.BASE_DOMAIN.endsWith(".localhost") &&
+ appUrl.username === "" &&
+ appUrl.password === "" &&
+ appUrl.pathname === "/" &&
+ appUrl.search === "" &&
+ appUrl.hash === "";
+ if (!isLocalOrigin) {
+ throw new Error(
+ "DEV_EMAIL_PASSWORD_AUTH is only allowed for local Cloud development",
+ );
+ }
+ return true;
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 984700df00..b2e5d36bce 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -5,6 +5,37 @@ 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 cloudDevServerUrlTemplate =
+ process.env.BB_CLOUD_DEV_SERVER_URL_TEMPLATE?.trim();
+const cloudDevBaseDomain = cloudDevAppUrl
+ ? new URL(cloudDevAppUrl).hostname
+ : undefined;
+
+const cloudDevConfig =
+ cloudDevStatePath &&
+ cloudDevAppUrl &&
+ cloudDevServerUrlTemplate &&
+ cloudDevBaseDomain
+ ? {
+ persistState: { path: cloudDevStatePath },
+ config: (config: { vars?: Record }) => ({
+ vars: {
+ ...config.vars,
+ APP_URL: cloudDevAppUrl,
+ BASE_DOMAIN: cloudDevBaseDomain,
+ BETTER_AUTH_SECRET:
+ "6c9e2f41a7d58b30c4e918f267bd5a0c3f1468e2d9a57b04c8f31a6d72e95b40",
+ CONNECT_SERVER_URL_TEMPLATE: cloudDevServerUrlTemplate,
+ DEV_EMAIL_PASSWORD_AUTH: "true",
+ GITHUB_CLIENT_ID: "local-cloud-dev-unused",
+ GITHUB_CLIENT_SECRET: "local-cloud-dev-unused",
+ },
+ }),
+ }
+ : {};
+
export default defineConfig({
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
@@ -12,10 +43,10 @@ export default defineConfig({
// Dev binds all interfaces so the server is reachable over the tailnet
// (see the dev script's --host 0.0.0.0); allow Tailscale MagicDNS names.
server: {
- allowedHosts: [".ts.net"],
+ allowedHosts: [".localhost", ".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 f2e4486433..ba4d2314b2 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -59,6 +59,14 @@ For the packaged app, prefer `bb-app config`, `bb-app env`, and launcher flags
over shell variables. The environment remains the internal and deployment
substrate, and source-development commands still load `.env` files.
+For source development, `pnpm dev` automatically injects
+`BB_DEV_CONNECT_BASE_URL=http://bb.localhost:`. The
+Connect plugin accepts this loopback origin only when `NODE_ENV=development`
+and uses it only as the unpaired default. Explicit `bb connect --server ...`
+or `--base-url ...` targets take precedence, and packaged/production bb keeps
+the `https://getbb.app` default. This value is launcher-managed, not a
+`bb-app config` setting.
+
After `bb-app config` writes `~/.bb/config.json` or `bb-app env` writes
`~/.bb/env.json`, it asks the running local server to reload. If bb is not
running, the new values apply on the next start. If you edit either file by
diff --git a/docs/debugging-and-qa.md b/docs/debugging-and-qa.md
index 095bf3dc2a..a3a27df68e 100644
--- a/docs/debugging-and-qa.md
+++ b/docs/debugging-and-qa.md
@@ -30,3 +30,30 @@ Test agents with:
eval "$(scripts/bb-dev-app env)"
pnpm bb:dev thread spawn --project proj_personal --provider codex --permission-mode accept-edits --title "Smoke test" --prompt "Reply only with ok." --json
```
+
+## Local Cloud
+
+Run the Cloud dashboard and Connect worker against one local D1 database:
+
+```bash
+pnpm cloud:dev
+```
+
+The command applies migrations and prints the dashboard URL. Create a local
+email/password account, claim a handle, create a pairing code, and run the
+displayed `bb connect` command against a bb started with `pnpm dev`. The same
+worktree-specific local origin serves the dashboard at `bb.localhost` and
+routes `.bb.localhost` through the Connect worker. Email/password auth
+is enabled only for this loopback workflow; production remains GitHub-only.
+`pnpm dev` automatically sets `BB_DEV_CONNECT_BASE_URL` to that worktree's
+local Cloud origin. While the bb is unpaired, Settings → Plugins → Connect
+therefore opens the local dashboard and a pasted code redeems locally. An
+explicit `bb connect --server ...` or `--base-url ...` still wins, so the dev bb
+can still pair with getbb.app.
+Local machine enrollment follows the same origin: local `http:` server URLs
+produce `ws:` machine tunnels and `http:` share URLs, while non-local machine
+enrollment remains HTTPS-only.
+
+To test the AI gateway, copy `apps/connect/.dev.vars.example` to the ignored
+`apps/connect/.dev.vars` and set `OPENAI_API_KEY`. Ctrl-C stops the local
+services. Local D1 state is kept under `.wrangler/cloud-dev`.
diff --git a/package.json b/package.json
index 7b24c19717..9a017aaf98 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"cli:prepare": "pnpm exec turbo run build --filter=@bb/scripts --filter=@bb/cli --output-logs=none --log-prefix=none --summarize=false",
"ensure-native-modules": "node scripts/ensure-native-modules.mjs",
"dev": "node scripts/ensure-native-modules.mjs && cross-env NODE_ENV=development dotenv -c development -- node --conditions=source --import tsx packages/scripts/src/commands/run-dev.ts",
+ "cloud:dev": "node --conditions=source --import tsx scripts/bb-cloud-dev.mjs",
"dev:desktop": "scripts/bb-dev-app current --desktop",
"dev:status": "scripts/bb-dev-app status",
"dev:restart": "cross-env NODE_ENV=development node --conditions=source --import tsx packages/scripts/src/commands/request-dev-restart.ts both",
@@ -46,6 +47,7 @@
"cross-env": "^10.1.0",
"dotenv-cli": "^11.0.0",
"esbuild": "^0.28.0",
+ "http-proxy-3": "^1.23.2",
"prettier": "^3.8.3",
"rimraf": "^6.1.0",
"tsx": "^4.23.1",
diff --git a/packages/config/src/runtime.ts b/packages/config/src/runtime.ts
index cfbc43c6b1..60d17187bf 100644
--- a/packages/config/src/runtime.ts
+++ b/packages/config/src/runtime.ts
@@ -6,6 +6,8 @@ export type BbRuntimeMode = "dev" | "prod";
export interface DevPortSet {
appPort: number;
+ cloudPort: number;
+ cloudWorkerPort: number;
hostDaemonPort: number;
serverPort: number;
}
@@ -88,6 +90,8 @@ const DEV_PORT_BUCKETS = 8_000;
const DEV_APP_PORT_BASE = 11_000;
const DEV_SERVER_PORT_BASE = 19_000;
const DEV_HOST_DAEMON_PORT_BASE = 27_000;
+const DEV_CLOUD_PORT_BASE = 35_000;
+const DEV_CLOUD_WORKER_PORT_BASE = 43_000;
const DEV_PROCESS_STRIPPED_ENV_KEYS: readonly string[] = [
"BB_ENVIRONMENT_ID",
"BB_THREAD_ID",
@@ -134,10 +138,20 @@ 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,
+ cloudPort: skipPackagedAppPorts(DEV_CLOUD_PORT_BASE + offset),
+ cloudWorkerPort: DEV_CLOUD_WORKER_PORT_BASE + offset,
hostDaemonPort: DEV_HOST_DAEMON_PORT_BASE + offset,
serverPort: DEV_SERVER_PORT_BASE + offset,
};
@@ -305,6 +319,7 @@ export function toDevProcessEnv(args: DevProcessEnvArgs): NodeJS.ProcessEnv {
...env,
BB_DATA_DIR: args.config.dataDir,
BB_DEV_APP_PORT: String(args.config.ports.appPort),
+ BB_DEV_CONNECT_BASE_URL: `http://bb.localhost:${args.config.ports.cloudPort}`,
BB_HOST_DAEMON_PORT: String(args.config.ports.hostDaemonPort),
...(inheritedSkillsRootPaths.length > 0
? { BB_INHERITED_SKILLS_ROOTS: inheritedSkillsRootPaths.join(delimiter) }
diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts
index e3ff56895d..78a90127aa 100644
--- a/packages/host-daemon-contract/src/commands.ts
+++ b/packages/host-daemon-contract/src/commands.ts
@@ -35,7 +35,7 @@ import {
providerCliStatusResponseSchema,
} from "./local.js";
-export const HOST_DAEMON_PROTOCOL_VERSION = 90 as const;
+export const HOST_DAEMON_PROTOCOL_VERSION = 91 as const;
export {
BRANCH_LIST_LIMIT_MAX,
@@ -78,6 +78,8 @@ export const hostDaemonConnectTunnelIdentitySchema = z
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/)
.refine((label) => !label.includes("--")),
baseDomain: z.string().min(1).refine(isConnectBaseDomain),
+ /** Public gate protocol. HTTP is accepted only for local *.localhost. */
+ protocol: z.enum(["http:", "https:"]),
})
.strict();
export type HostDaemonConnectTunnelIdentity = z.infer<
diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts
index faea73c54b..dbd36bced4 100644
--- a/packages/host-daemon-contract/test/contract.test.ts
+++ b/packages/host-daemon-contract/test/contract.test.ts
@@ -170,6 +170,7 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = {
"connect-tunnel.ensure-identity": {
label: "sawyer-air",
baseDomain: "getbb.app",
+ protocol: "https:",
},
"host.list_files": {
files: [
@@ -1039,12 +1040,10 @@ describe("host-daemon local schemas", () => {
});
describe("host-daemon command schemas", () => {
- // Version 90 adds the `plan` approval subject that carries a Claude plan to
- // the user for review. An enrolled daemon on an older build cannot raise one,
- // so it silently leaves Plan mode instead of asking, and it would reject the
- // subject if the server sent one back.
- it("uses protocol version 90 for plan-review approvals", () => {
- expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(90);
+ // Version 91 adds the public protocol to Connect tunnel identities so local
+ // Cloud daemons use HTTP/WebSocket while production remains HTTPS/WSS.
+ it("uses protocol version 91 for protocol-aware Connect tunnels", () => {
+ expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(91);
});
it("binds Plan cancellation to a required turn id and typed result", () => {
@@ -3370,11 +3369,19 @@ describe("host-daemon session schemas", () => {
expect(
hostDaemonDaemonWsMessageSchema.parse({
type: "connect-tunnel.identity",
- identity: { label: "sawyer-air", baseDomain: "getbb.app" },
+ identity: {
+ label: "sawyer-air",
+ baseDomain: "getbb.app",
+ protocol: "https:",
+ },
}),
).toEqual({
type: "connect-tunnel.identity",
- identity: { label: "sawyer-air", baseDomain: "getbb.app" },
+ identity: {
+ label: "sawyer-air",
+ baseDomain: "getbb.app",
+ protocol: "https:",
+ },
});
expect(
diff --git a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
index 721c024cb3..67313a722c 100644
--- a/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
+++ b/packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts
@@ -4837,6 +4837,10 @@ declare const hostDaemonCommandRegistry: {
}, z$1.core.$strict>, z$1.ZodObject<{
label: z$1.ZodString;
baseDomain: z$1.ZodString;
+ protocol: z$1.ZodEnum<{
+ "http:": "http:";
+ "https:": "https:";
+ }>;
}, z$1.core.$strict>, "onlineRpc", true>;
"host.list_commands": HostDaemonCommandDescriptor<"host.list_commands", z$1.ZodObject<{
type: z$1.ZodLiteral<"host.list_commands">;
diff --git a/packages/scripts/test/dev-instance-expectations.ts b/packages/scripts/test/dev-instance-expectations.ts
index 2a35e92448..45304551dd 100644
--- a/packages/scripts/test/dev-instance-expectations.ts
+++ b/packages/scripts/test/dev-instance-expectations.ts
@@ -3,6 +3,8 @@ import { isAbsolute, join, relative } from "node:path";
export interface ExpectedDevPortSet {
appPort: number;
+ cloudPort: number;
+ cloudWorkerPort: number;
hostDaemonPort: number;
serverPort: number;
}
@@ -26,10 +28,20 @@ function expectedPortOffset(repoRoot: string): number {
);
}
+function skipPackagedAppPorts(port: number): number {
+ let availablePort = port;
+ for (const reservedPort of [38_886, 38_887]) {
+ if (availablePort >= reservedPort) availablePort += 1;
+ }
+ return availablePort;
+}
+
export function expectedDevPorts(repoRoot: string): ExpectedDevPortSet {
const offset = expectedPortOffset(repoRoot);
return {
appPort: 11_000 + offset,
+ cloudPort: skipPackagedAppPorts(35_000 + offset),
+ cloudWorkerPort: 43_000 + offset,
hostDaemonPort: 27_000 + offset,
serverPort: 19_000 + offset,
};
diff --git a/packages/scripts/test/run-dev.test.ts b/packages/scripts/test/run-dev.test.ts
index ccd2075f7f..4a9b71684c 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);
@@ -97,6 +97,9 @@ describe("run-dev", () => {
expect(env.BB_SERVER_URL).toBe(config.serverUrl);
expect(env.BB_HOST_DAEMON_PORT).toBe(String(config.ports.hostDaemonPort));
expect(env.BB_DEV_APP_PORT).toBe(String(config.ports.appPort));
+ expect(env.BB_DEV_CONNECT_BASE_URL).toBe(
+ `http://bb.localhost:${config.ports.cloudPort}`,
+ );
});
it("inherits parent bb skills for managed worktree dev apps", () => {
diff --git a/packages/templates/src/generated/plugin-sdk-dts.generated.ts b/packages/templates/src/generated/plugin-sdk-dts.generated.ts
index 1a4d12d190..dfd93249a3 100644
--- a/packages/templates/src/generated/plugin-sdk-dts.generated.ts
+++ b/packages/templates/src/generated/plugin-sdk-dts.generated.ts
@@ -2,6 +2,6 @@
// Generated by packages/templates/scripts/generate-templates.mjs from
// @bb/plugin-sdk/bundled-types. Do not edit directly.
-export const PLUGIN_SDK_DTS = "// Portable type declarations for `@bb/plugin-sdk`. Unpublished BB\n// workspace contracts are flattened; public subpaths may reuse the\n// package root without requiring any other @bb/* package.\n//\n// Confused by the API, or need a symbol that isn't here? Clone the BB repo\n// and read the real source: https://github.com/get-bb/bb\n\nimport * as react from 'react';\nimport { ComponentType, ReactNode } from 'react';\nimport * as z from 'zod';\nimport { z as z$1 } from 'zod';\nimport Database from 'better-sqlite3';\nimport { Context } from 'hono';\n\n/**\n * App-wide server-backed preferences.\n * Client-local settings stay in the frontend localStorage helpers instead.\n */\ndeclare const appSettingsSchema: z$1.ZodObject<{\n caffeinate: z$1.ZodBoolean;\n showKeyboardHints: z$1.ZodBoolean;\n steerActiveThreadOnEnter: z$1.ZodBoolean;\n showUnhandledProviderEvents: z$1.ZodBoolean;\n codexMemoryEnabled: z$1.ZodBoolean;\n claudeCodeMemoryEnabled: z$1.ZodBoolean;\n codexSubagentsDisabled: z$1.ZodBoolean;\n claudeCodeSubagentsDisabled: z$1.ZodBoolean;\n claudeCodeWorkflowsDisabled: z$1.ZodBoolean;\n onboardingCompletedAt: z$1.ZodNullable;\n}, z$1.core.$strict>;\ntype AppSettings = z$1.infer;\n\ndeclare const appKeybindingOverridesSchema: z$1.ZodArray;\n shortcut: z$1.ZodNullable>;\n}, z$1.core.$strict>>;\ntype AppKeybindingOverrides = z$1.infer;\n\ndeclare const appThemeSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n customCss: z$1.ZodNullable;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppTheme = z$1.infer;\n/**\n * The complete appearance selection a client sends when changing the palette\n * and/or favicon tint. The server validates `themeId` (built-in id or an\n * existing custom theme) and resolves the CSS from disk for custom themes.\n * Callers changing only one facet must carry the other facet forward explicitly.\n */\ndeclare const appThemeSelectionSchema: z$1.ZodObject<{\n themeId: z$1.ZodString;\n faviconColor: z$1.ZodEnum<{\n default: \"default\";\n red: \"red\";\n orange: \"orange\";\n yellow: \"yellow\";\n green: \"green\";\n teal: \"teal\";\n blue: \"blue\";\n purple: \"purple\";\n pink: \"pink\";\n }>;\n}, z$1.core.$strip>;\ntype AppThemeSelection = z$1.infer;\n\ndeclare const changedMessageSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"thread\">;\n id: z$1.ZodOptional;\n metadata: z$1.ZodOptional;\n eventTypes: z$1.ZodOptional>>>>;\n hasPendingInteraction: z$1.ZodOptional;\n projectId: z$1.ZodOptional;\n }, z$1.core.$strict>>;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"project\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"environment\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"host\">;\n id: z$1.ZodOptional;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"changed\">;\n entity: z$1.ZodLiteral<\"system\">;\n changes: z$1.ZodReadonly>>;\n}, z$1.core.$strict>], \"entity\">;\ntype ChangedMessage = z$1.infer;\n\ndeclare const environmentSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n name: z$1.ZodNullable;\n projectId: z$1.ZodString;\n hostId: z$1.ZodString;\n path: z$1.ZodNullable;\n managed: z$1.ZodBoolean;\n isGitRepo: z$1.ZodBoolean;\n isWorktree: z$1.ZodBoolean;\n workspaceProvisionType: z$1.ZodEnum<{\n unmanaged: \"unmanaged\";\n \"managed-worktree\": \"managed-worktree\";\n personal: \"personal\";\n }>;\n branchName: z$1.ZodNullable;\n baseBranch: z$1.ZodNullable;\n defaultBranch: z$1.ZodNullable;\n mergeBaseBranch: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n error: \"error\";\n provisioning: \"provisioning\";\n ready: \"ready\";\n retiring: \"retiring\";\n destroying: \"destroying\";\n destroyed: \"destroyed\";\n }>;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n}, z$1.core.$strip>;\ntype Environment = z$1.infer;\n\ndeclare const experimentsSchema: z$1.ZodRecord, z$1.ZodBoolean>;\ntype Experiments = z$1.infer;\n\ndeclare const hostSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n name: z$1.ZodString;\n type: z$1.ZodEnum<{\n persistent: \"persistent\";\n }>;\n status: z$1.ZodEnum<{\n connected: \"connected\";\n disconnected: \"disconnected\";\n }>;\n maxPermissionMode: z$1.ZodEnum<{\n full: \"full\";\n auto: \"auto\";\n \"accept-edits\": \"accept-edits\";\n }>;\n lastSeenAt: z$1.ZodNullable;\n lastRejectedProtocolVersion: z$1.ZodNullable;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n}, z$1.core.$strip>;\ntype Host = z$1.infer;\n\ninterface JsonObject {\n [key: string]: JsonValue$1;\n}\ntype JsonValue$1 = string | number | boolean | null | JsonValue$1[] | JsonObject;\n\ndeclare const pendingInteractionResolutionSchema: z$1.ZodUnion;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n}, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_answer\">;\n answers: z$1.ZodRecord;\n freeText: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin_submitted\">;\n}, z$1.core.$strip>]>;\ntype PendingInteractionResolution = z$1.infer;\ndeclare const providerPendingInteractionSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n threadId: z$1.ZodString;\n status: z$1.ZodEnum<{\n pending: \"pending\";\n interrupted: \"interrupted\";\n resolving: \"resolving\";\n resolved: \"resolved\";\n }>;\n statusReason: z$1.ZodNullable;\n createdAt: z$1.ZodNumber;\n expiresAt: z$1.ZodOptional>;\n resolvedAt: z$1.ZodNullable;\n turnId: z$1.ZodString;\n providerId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n providerRequestId: z$1.ZodString;\n origin: z$1.ZodOptional;\n providerId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n providerRequestId: z$1.ZodString;\n }, z$1.core.$strip>>;\n payload: z$1.ZodUnion;\n subject: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n itemId: z$1.ZodString;\n command: z$1.ZodString;\n cwd: z$1.ZodNullable;\n actions: z$1.ZodArray;\n command: z$1.ZodString;\n name: z$1.ZodString;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"listFiles\">;\n command: z$1.ZodString;\n path: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"search\">;\n command: z$1.ZodString;\n query: z$1.ZodNullable;\n path: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"unknown\">;\n command: z$1.ZodString;\n }, z$1.core.$strip>], \"type\">>;\n sessionGrant: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"file_change\">;\n itemId: z$1.ZodString;\n writeScope: z$1.ZodNullable;\n sessionGrant: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"permission_grant\">;\n itemId: z$1.ZodString;\n toolName: z$1.ZodNullable;\n permissions: z$1.ZodObject<{\n network: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plan\">;\n itemId: z$1.ZodString;\n plan: z$1.ZodString;\n planFilePath: z$1.ZodNullable;\n }, z$1.core.$strip>], \"kind\">;\n reason: z$1.ZodNullable;\n availableDecisions: z$1.ZodArray>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_question\">;\n questions: z$1.ZodArray;\n multiSelect: z$1.ZodBoolean;\n options: z$1.ZodOptional;\n }, z$1.core.$strip>>>;\n allowFreeText: z$1.ZodBoolean;\n }, z$1.core.$strip>>;\n }, z$1.core.$strip>]>;\n resolution: z$1.ZodNullable;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"allow_for_session\">;\n grantedPermissions: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n fileSystem: z$1.ZodNullable;\n write: z$1.ZodArray;\n }, z$1.core.$strip>>;\n }, z$1.core.$strict>>;\n }, z$1.core.$strip>, z$1.ZodObject<{\n decision: z$1.ZodLiteral<\"deny\">;\n }, z$1.core.$strip>], \"decision\">, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"user_answer\">;\n answers: z$1.ZodRecord;\n freeText: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n }, z$1.core.$strip>]>>;\n}, z$1.core.$strip>;\ntype ProviderPendingInteraction = z$1.infer;\ndeclare const pluginPendingInteractionSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n threadId: z$1.ZodString;\n status: z$1.ZodEnum<{\n pending: \"pending\";\n interrupted: \"interrupted\";\n resolving: \"resolving\";\n resolved: \"resolved\";\n }>;\n statusReason: z$1.ZodNullable;\n createdAt: z$1.ZodNumber;\n expiresAt: z$1.ZodOptional>;\n resolvedAt: z$1.ZodNullable;\n turnId: z$1.ZodNullable;\n origin: z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n rendererId: z$1.ZodString;\n }, z$1.core.$strip>;\n payload: z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n title: z$1.ZodString;\n data: z$1.ZodType>;\n }, z$1.core.$strip>;\n resolution: z$1.ZodNullable;\n }, z$1.core.$strip>>;\n}, z$1.core.$strip>;\ntype PluginPendingInteraction = z$1.infer;\ntype PendingInteraction = ProviderPendingInteraction | PluginPendingInteraction;\n\ndeclare const projectSourceSchema: z$1.ZodObject<{\n id: z$1.ZodString;\n projectId: z$1.ZodString;\n isDefault: z$1.ZodBoolean;\n createdAt: z$1.ZodNumber;\n updatedAt: z$1.ZodNumber;\n type: z$1.ZodLiteral<\"local_path\">;\n hostId: z$1.ZodString;\n path: z$1.ZodString;\n}, z$1.core.$strip>;\ntype ProjectSource = z$1.infer;\n\ndeclare const reasoningLevelSchema: z$1.ZodEnum<{\n none: \"none\";\n low: \"low\";\n medium: \"medium\";\n high: \"high\";\n xhigh: \"xhigh\";\n ultracode: \"ultracode\";\n max: \"max\";\n ultra: \"ultra\";\n}>;\ntype ReasoningLevel = z$1.infer;\ndeclare const serviceTierSchema: z$1.ZodEnum<{\n default: \"default\";\n fast: \"fast\";\n}>;\ntype ServiceTier = z$1.infer;\ndeclare const permissionModeSchema: z$1.ZodEnum<{\n full: \"full\";\n auto: \"auto\";\n \"accept-edits\": \"accept-edits\";\n}>;\ntype PermissionMode = z$1.infer;\ndeclare const promptInputSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"text\">;\n text: z$1.ZodString;\n mentions: z$1.ZodDefault, z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n threadId: z$1.ZodString;\n projectId: z$1.ZodOptional;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"project\">;\n projectId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"section\">;\n sectionId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"path\">;\n source: z$1.ZodEnum<{\n workspace: \"workspace\";\n \"thread-storage\": \"thread-storage\";\n }>;\n entryKind: z$1.ZodEnum<{\n file: \"file\";\n directory: \"directory\";\n }>;\n path: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"command\">;\n trigger: z$1.ZodEnum<{\n \"/\": \"/\";\n }>;\n name: z$1.ZodString;\n source: z$1.ZodEnum<{\n command: \"command\";\n skill: \"skill\";\n }>;\n origin: z$1.ZodEnum<{\n user: \"user\";\n project: \"project\";\n builtin: \"builtin\";\n }>;\n label: z$1.ZodString;\n argumentHint: z$1.ZodNullable;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"plugin\">;\n pluginId: z$1.ZodString;\n icon: z$1.ZodOptional>;\n itemId: z$1.ZodString;\n label: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">>;\n }, z$1.core.$strip>>>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n}, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n}, z$1.core.$strip>, z$1.ZodObject<{\n visibility: z$1.ZodOptional>;\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n name: z$1.ZodOptional;\n sizeBytes: z$1.ZodOptional;\n mimeType: z$1.ZodOptional;\n}, z$1.core.$strip>], \"type\">;\ntype PromptInput = z$1.infer;\ndeclare const resolvedThreadExecutionOptionsSchema: z$1.ZodObject<{\n seq: z$1.ZodOptional;\n model: z$1.ZodString;\n serviceTier: z$1.ZodEnum<{\n default: \"default\";\n fast: \"fast\";\n }>;\n reasoningLevel: z$1.ZodEnum<{\n none: \"none\";\n low: \"low\";\n medium: \"medium\";\n high: \"high\";\n xhigh: \"xhigh\";\n ultracode: \"ultracode\";\n max: \"max\";\n ultra: \"ultra\";\n }>;\n permissionMode: z$1.ZodEnum<{\n full: \"full\";\n auto: \"auto\";\n \"accept-edits\": \"accept-edits\";\n }>;\n source: z$1.ZodEnum<{\n \"client/thread/start\": \"client/thread/start\";\n \"client/turn/requested\": \"client/turn/requested\";\n \"client/turn/start\": \"client/turn/start\";\n }>;\n}, z$1.core.$strip>;\ntype ResolvedThreadExecutionOptions = z$1.infer;\ndeclare const projectExecutionDefaultsSchema: z$1.ZodObject<{\n providerId: z$1.ZodString;\n model: z$1.ZodString;\n serviceTier: z$1.ZodEnum<{\n default: \"default\";\n fast: \"fast\";\n }>;\n reasoningLevel: z$1.ZodEnum<{\n none: \"none\";\n low: \"low\";\n medium: \"medium\";\n high: \"high\";\n xhigh: \"xhigh\";\n ultracode: \"ultracode\";\n max: \"max\";\n ultra: \"ultra\";\n }>;\n permissionMode: z$1.ZodEnum<{\n full: \"full\";\n auto: \"auto\";\n \"accept-edits\": \"accept-edits\";\n }>;\n}, z$1.core.$strip>;\ntype ProjectExecutionDefaults = z$1.infer;\n\n/** All thread events — provider-originated or system-originated. */\ndeclare const threadEventSchema: z$1.ZodPipe;\n threadId: z$1.ZodString;\n}, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"thread/identity\">;\n threadId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n}, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"turn/started\">;\n threadId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n}, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"turn/completed\">;\n threadId: z$1.ZodString;\n providerThreadId: z$1.ZodNullable;\n status: z$1.ZodEnum<{\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n }>;\n error: z$1.ZodOptional>;\n}, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"turn/input/accepted\">;\n threadId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n clientRequestId: z$1.ZodString;\n scope: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"thread\">;\n }, z$1.core.$strip>, z$1.ZodObject<{\n kind: z$1.ZodLiteral<\"turn\">;\n turnId: z$1.ZodString;\n }, z$1.core.$strip>], \"kind\">;\n}, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"thread/name/updated\">;\n threadId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n threadName: z$1.ZodString;\n}, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"thread/compacted\">;\n threadId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n}, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"thread/goal/updated\">;\n threadId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n objective: z$1.ZodString;\n status: z$1.ZodEnum<{\n paused: \"paused\";\n active: \"active\";\n budgetLimited: \"budgetLimited\";\n complete: \"complete\";\n }>;\n tokenBudget: z$1.ZodNullable;\n tokensUsed: z$1.ZodNumber;\n timeUsedSeconds: z$1.ZodNumber;\n}, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"thread/goal/cleared\">;\n threadId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n}, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"item/started\">;\n threadId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n item: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"userMessage\">;\n id: z$1.ZodString;\n content: z$1.ZodArray;\n text: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>], \"type\">>;\n clientRequestId: z$1.ZodOptional;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"agentMessage\">;\n id: z$1.ZodString;\n text: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"commandExecution\">;\n id: z$1.ZodString;\n command: z$1.ZodString;\n cwd: z$1.ZodString;\n status: z$1.ZodEnum<{\n pending: \"pending\";\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n }>;\n approvalStatus: z$1.ZodNullable>;\n aggregatedOutput: z$1.ZodOptional;\n exitCode: z$1.ZodOptional;\n durationMs: z$1.ZodOptional;\n truncation: z$1.ZodOptional>;\n result: z$1.ZodOptional>;\n resultText: z$1.ZodOptional>;\n }, z$1.core.$strip>>;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"fileChange\">;\n id: z$1.ZodString;\n changes: z$1.ZodArray;\n movePath: z$1.ZodOptional;\n diff: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n status: z$1.ZodEnum<{\n pending: \"pending\";\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n }>;\n approvalStatus: z$1.ZodNullable>;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"webSearch\">;\n id: z$1.ZodString;\n queries: z$1.ZodArray;\n resultText: z$1.ZodNullable;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"webFetch\">;\n id: z$1.ZodString;\n url: z$1.ZodString;\n prompt: z$1.ZodNullable;\n pattern: z$1.ZodNullable;\n resultText: z$1.ZodNullable;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"imageView\">;\n id: z$1.ZodString;\n path: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"toolCall\">;\n id: z$1.ZodString;\n server: z$1.ZodOptional;\n tool: z$1.ZodString;\n arguments: z$1.ZodOptional>;\n statusLabels: z$1.ZodOptional>;\n status: z$1.ZodEnum<{\n pending: \"pending\";\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n }>;\n result: z$1.ZodOptional;\n error: z$1.ZodOptional;\n durationMs: z$1.ZodOptional;\n truncation: z$1.ZodOptional>;\n result: z$1.ZodOptional>;\n resultText: z$1.ZodOptional>;\n }, z$1.core.$strip>>;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reasoning\">;\n id: z$1.ZodString;\n summary: z$1.ZodArray;\n content: z$1.ZodArray;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"plan\">;\n id: z$1.ZodString;\n text: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"contextCompaction\">;\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"backgroundTask\">;\n id: z$1.ZodString;\n taskType: z$1.ZodString;\n description: z$1.ZodString;\n status: z$1.ZodEnum<{\n pending: \"pending\";\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n }>;\n taskStatus: z$1.ZodEnum<{\n pending: \"pending\";\n running: \"running\";\n paused: \"paused\";\n completed: \"completed\";\n failed: \"failed\";\n killed: \"killed\";\n stopped: \"stopped\";\n }>;\n skipTranscript: z$1.ZodBoolean;\n workflowName: z$1.ZodOptional;\n workflow: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n agents: z$1.ZodArray;\n model: z$1.ZodString;\n attempt: z$1.ZodNumber;\n cached: z$1.ZodBoolean;\n lastProgressAt: z$1.ZodNumber;\n phaseIndex: z$1.ZodOptional;\n phaseTitle: z$1.ZodOptional;\n agentType: z$1.ZodOptional;\n isolation: z$1.ZodOptional;\n queuedAt: z$1.ZodOptional;\n startedAt: z$1.ZodOptional;\n lastToolName: z$1.ZodOptional;\n lastToolSummary: z$1.ZodOptional;\n promptPreview: z$1.ZodOptional;\n resultPreview: z$1.ZodOptional;\n error: z$1.ZodOptional;\n tokens: z$1.ZodOptional;\n toolCalls: z$1.ZodOptional;\n durationMs: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n }, z$1.core.$strip>>;\n usage: z$1.ZodOptional>;\n summary: z$1.ZodOptional;\n error: z$1.ZodOptional;\n outputFile: z$1.ZodOptional;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>], \"type\">;\n}, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"item/completed\">;\n threadId: z$1.ZodString;\n providerThreadId: z$1.ZodString;\n item: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{\n type: z$1.ZodLiteral<\"userMessage\">;\n id: z$1.ZodString;\n content: z$1.ZodArray;\n text: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"image\">;\n url: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"localImage\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"localFile\">;\n path: z$1.ZodString;\n }, z$1.core.$strip>], \"type\">>;\n clientRequestId: z$1.ZodOptional;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strict>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"agentMessage\">;\n id: z$1.ZodString;\n text: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"commandExecution\">;\n id: z$1.ZodString;\n command: z$1.ZodString;\n cwd: z$1.ZodString;\n status: z$1.ZodEnum<{\n pending: \"pending\";\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n }>;\n approvalStatus: z$1.ZodNullable>;\n aggregatedOutput: z$1.ZodOptional;\n exitCode: z$1.ZodOptional;\n durationMs: z$1.ZodOptional;\n truncation: z$1.ZodOptional>;\n result: z$1.ZodOptional>;\n resultText: z$1.ZodOptional>;\n }, z$1.core.$strip>>;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"fileChange\">;\n id: z$1.ZodString;\n changes: z$1.ZodArray;\n movePath: z$1.ZodOptional;\n diff: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n status: z$1.ZodEnum<{\n pending: \"pending\";\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n }>;\n approvalStatus: z$1.ZodNullable>;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"webSearch\">;\n id: z$1.ZodString;\n queries: z$1.ZodArray;\n resultText: z$1.ZodNullable;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"webFetch\">;\n id: z$1.ZodString;\n url: z$1.ZodString;\n prompt: z$1.ZodNullable;\n pattern: z$1.ZodNullable;\n resultText: z$1.ZodNullable;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"imageView\">;\n id: z$1.ZodString;\n path: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"toolCall\">;\n id: z$1.ZodString;\n server: z$1.ZodOptional;\n tool: z$1.ZodString;\n arguments: z$1.ZodOptional>;\n statusLabels: z$1.ZodOptional>;\n status: z$1.ZodEnum<{\n pending: \"pending\";\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n }>;\n result: z$1.ZodOptional;\n error: z$1.ZodOptional;\n durationMs: z$1.ZodOptional;\n truncation: z$1.ZodOptional>;\n result: z$1.ZodOptional>;\n resultText: z$1.ZodOptional>;\n }, z$1.core.$strip>>;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"reasoning\">;\n id: z$1.ZodString;\n summary: z$1.ZodArray;\n content: z$1.ZodArray;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"plan\">;\n id: z$1.ZodString;\n text: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"contextCompaction\">;\n id: z$1.ZodString;\n parentToolCallId: z$1.ZodOptional;\n }, z$1.core.$strip>, z$1.ZodObject<{\n type: z$1.ZodLiteral<\"backgroundTask\">;\n id: z$1.ZodString;\n taskType: z$1.ZodString;\n description: z$1.ZodString;\n status: z$1.ZodEnum<{\n pending: \"pending\";\n completed: \"completed\";\n failed: \"failed\";\n interrupted: \"interrupted\";\n }>;\n taskStatus: z$1.ZodEnum<{\n pending: \"pending\";\n running: \"running\";\n paused: \"paused\";\n completed: \"completed\";\n failed: \"failed\";\n killed: \"killed\";\n stopped: \"stopped\";\n }>;\n skipTranscript: z$1.ZodBoolean;\n workflowName: z$1.ZodOptional;\n workflow: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n agents: z$1.ZodArray;\n model: z$1.ZodString;\n attempt: z$1.ZodNumber;\n cached: z$1.ZodBoolean;\n lastProgressAt: z$1.ZodNumber;\n phaseIndex: z$1.ZodOptional;\n phaseTitle: z$1.ZodOptional;\n agentType: z$1.ZodOptional;\n isolation: z$1.ZodOptional;\n queuedAt: z$1.ZodOptional;\n startedAt: z$1.ZodOptional;\n lastToolName: z$1.ZodOptional;\n lastToolSummary: z$1.ZodOptional;\n promptPreview: z$1.ZodOptional;\n resultPreview: z$1.ZodOptional;\n error: z$1.ZodOptional;\n tokens: z$1.ZodOptional;\n toolCalls: z$1.ZodOptional;\n durationMs: z$1.ZodOptional;\n }, z$1.core.$strip>>;\n }, z$1.core.$strip>>;\n usage: z$1.ZodOptional>;\n summary: z$1.ZodOptional;\n error: z$1.ZodOptional;\n outputFile: z$1.ZodOptional;\n parentToolCallId: z$1.ZodOptional