+
+
+ Mobile app
+
+
+ {payload === null ? (
+
+ ) : (
+
+ )}
+
+
+ {payload !== null ? (
+
+ ) : (
+
+ Pair the bb mobile app with this bb. It gets a one-time code to scan
+ or type; the phone then reaches this bb through {dashboardHost}.
+
+ )}
+
+ {errorCode === "machine_limit" ? (
+
+ ) : errorCode !== null ? (
+
+ {errorCode === "not_paired"
+ ? "This bb is no longer paired — re-pair, then try again."
+ : "Couldn't reach the Connect service to create a code — check your connection, then try again."}
+
+ ) : null}
+
+ );
+}
+
// ---------------------------------------------------------------------------
// Shared ports subsection (restyled into the section grammar).
// ---------------------------------------------------------------------------
@@ -653,7 +925,7 @@ function SharedPortsSection({
{hostOf(share.url)}
diff --git a/plugins/connect/src/cli.ts b/plugins/connect/src/cli.ts
index 47db848892..2313a396ca 100644
--- a/plugins/connect/src/cli.ts
+++ b/plugins/connect/src/cli.ts
@@ -1,5 +1,11 @@
import type { BbPluginApi, PluginCliResult } from "@get-bb/plugin-sdk";
+import {
+ mobilePairingPayload,
+ type MobilePairingPayload,
+} from "@bb/connect-client";
import type { ShareHostResolver } from "./hosts.js";
+import { MachineCodeError } from "./machine-code.js";
+import type { MobilePairingGate } from "./rpc.js";
import { parseSharePort } from "./shares.js";
import type { ConnectTunnel } from "./tunnel.js";
import type { ConnectStatus } from "./types.js";
@@ -7,7 +13,8 @@ import type { ConnectStatus } from "./types.js";
// `bb connect` — resolved through the plugin CLI proxy. The dashboard-issued
// command `npx -p bb-app@latest bb connect --code
--server ` must
// keep working verbatim, so the root command takes the pairing flags and
-// `status` / `off` / `expose` / `unexpose` / `shares` / `servers` are subcommands.
+// `status` / `off` / `expose` / `unexpose` / `shares` / `servers` /
+// `machine-code` are subcommands.
interface ParsedFlags {
flags: Map;
@@ -76,6 +83,9 @@ function helpText(): string {
" bb connect unexpose [--host ] Stop sharing a port on that host",
" bb connect shares [--host ] List shares for the thread's host",
" bb connect servers List every bb on this account (from getbb.app)",
+ " bb connect machine-code Mint a one-time code that enrolls the bb mobile app (or another",
+ " device) as a connect machine for this bb (needs the",
+ ' "Mobile app" experiment in Settings → Experiments)',
"",
"The server holds the tunnel; it stays up while bb is running.",
].join("\n");
@@ -108,12 +118,54 @@ function notPairedError(): string {
return "this bb is not connected to getbb.app — run `bb connect` for how to pair";
}
+/**
+ * Human copy for a failed `machine-code`. The typed code is stable (the panel
+ * maps the same codes); the dashboard host is named so the limit message tells
+ * the user where to free a slot.
+ */
+function machineCodeErrorText(
+ error: MachineCodeError,
+ dashboardUrl: string,
+): string {
+ switch (error.code) {
+ case "not_paired":
+ return notPairedError();
+ case "machine_limit":
+ return `this account has reached its connect machine limit — revoke a device you no longer use at ${dashboardUrl}, then try again`;
+ case "network":
+ return "could not reach the connect service to mint a machine code — check the connection and try again";
+ }
+}
+
+function mobilePairingDisabledError(): string {
+ return 'mobile pairing is off — turn on the "Mobile app" experiment in Settings → Experiments (or `bb settings experiment mobileApp true`), then run this again';
+}
+
+function formatMachineCode(payload: MobilePairingPayload): string {
+ const minutes = Math.max(
+ 0,
+ Math.round((payload.expiresAt - Date.now()) / 60_000),
+ );
+ return [
+ `Code: ${payload.code}`,
+ `Server: ${payload.serverUrl}`,
+ `Apex: ${payload.apex}`,
+ `Expires: ${new Date(payload.expiresAt).toISOString()} (in about ${minutes} min)`,
+ "",
+ "Enter the code in the bb mobile app when it asks to pair over bb connect (or",
+ "scan the QR code from Settings → Remote access → Add mobile device). The phone",
+ "enrolls as a connect machine on this account — it appears in the getbb.app",
+ "dashboard's machine list, where you can revoke it. The code works once.",
+ ].join("\n");
+}
+
export function registerConnectCli(args: {
bb: Pick;
tunnel: ConnectTunnel;
hostResolver: ShareHostResolver;
+ mobilePairing: MobilePairingGate;
}): void {
- const { bb, tunnel, hostResolver } = args;
+ const { bb, tunnel, hostResolver, mobilePairing } = args;
bb.cli.register({
name: "connect",
summary:
@@ -149,6 +201,12 @@ export function registerConnectCli(args: {
summary: "List every bb server on this account",
usage: "bb connect servers [--json]",
},
+ {
+ name: "machine-code",
+ summary:
+ 'Mint a one-time code that enrolls the bb mobile app as a connect machine (needs the "Mobile app" experiment)',
+ usage: "bb connect machine-code [--json]",
+ },
],
async run(argv, ctx): Promise {
try {
@@ -294,6 +352,32 @@ export function registerConnectCli(args: {
];
return { exitCode: 0, stdout: `${lines.join("\n")}\n` };
}
+ if (first === "machine-code") {
+ const parsed = parseFlags(argv.slice(1));
+ validateFlags(parsed, { boolean: ["json"] });
+ if (!(await mobilePairing.enabled())) {
+ return {
+ exitCode: 1,
+ stderr: `${mobilePairingDisabledError()}\n`,
+ };
+ }
+ let payload: MobilePairingPayload;
+ try {
+ payload = mobilePairingPayload(await tunnel.createMachineCode());
+ } catch (error) {
+ if (error instanceof MachineCodeError) {
+ return {
+ exitCode: 1,
+ stderr: `${machineCodeErrorText(error, tunnel.status().dashboardUrl)}\n`,
+ };
+ }
+ throw error;
+ }
+ if (parsed.flags.has("json")) {
+ return { exitCode: 0, stdout: asJson(payload) };
+ }
+ return { exitCode: 0, stdout: `${formatMachineCode(payload)}\n` };
+ }
if (first !== undefined && !first.startsWith("--")) {
return {
exitCode: 1,
diff --git a/plugins/connect/src/connect.test.ts b/plugins/connect/src/connect.test.ts
index 94bdd51fb2..03295d0664 100644
--- a/plugins/connect/src/connect.test.ts
+++ b/plugins/connect/src/connect.test.ts
@@ -39,12 +39,18 @@ const REMOTE_HOST_NAME = "Sawyer Air";
function createConnectFakeHost(options?: {
remoteIdentity?: { label: string; baseDomain: string };
+ /** The `mobileApp` experiment (defaults on so pairing paths are exercised). */
+ mobileApp?: boolean;
}): FakePluginHost {
return createFakePluginHost({
pluginId: "connect",
sdk: {
system: {
- config: async () => ({ primaryHostId: SERVER_HOST_ID }) as never,
+ config: async () =>
+ ({
+ primaryHostId: SERVER_HOST_ID,
+ experiments: { mobileApp: options?.mobileApp ?? true },
+ }) as never,
},
hosts: {
get: async ({ hostId }: { hostId: string }) => {
@@ -2389,8 +2395,10 @@ describe("connect CLI", () => {
vi.unstubAllGlobals();
});
- async function loadCli(): Promise {
- host = createConnectFakeHost();
+ async function loadCli(options?: {
+ mobileApp?: boolean;
+ }): Promise {
+ host = createConnectFakeHost(options);
await plugin(host.bb as unknown as Parameters[0]);
return host;
}
@@ -2534,6 +2542,121 @@ describe("connect CLI", () => {
);
});
+ it("machine-code is off until the mobileApp experiment is on", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ const { harness } = await loadCli({ mobileApp: false });
+ const result = await harness.runCli(["machine-code"]);
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain('"Mobile app" experiment');
+ expect(result.stderr).toContain("bb settings experiment mobileApp true");
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("machine-code when unpaired errors clearly", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ const { harness } = await loadCli();
+ const result = await harness.runCli(["machine-code"]);
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain("not connected to getbb.app");
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("machine-code prints the pairing payload as text or json", async () => {
+ const fetchMock = vi.fn(
+ async (input: RequestInfo | URL, _init?: RequestInit) => {
+ const url = String(input);
+ if (url.includes("/api/connect/redeem")) {
+ return new Response(
+ JSON.stringify({ credential: "bbcred_live", handle: "sawyer" }),
+ { status: 200 },
+ );
+ }
+ if (url === "https://getbb.app/api/connect/machine-code") {
+ return new Response(
+ JSON.stringify({
+ code: "K7QP-2M4X",
+ expiresInMs: 600_000,
+ serverUrl: "https://sawyer.getbb.app",
+ }),
+ );
+ }
+ return new Response("not found", { status: 404 });
+ },
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ const { harness } = await loadCli();
+ await harness.runCli([
+ "--code",
+ "ABCD",
+ "--server",
+ "https://sawyer.getbb.app",
+ ]);
+
+ const before = Date.now();
+ const text = await harness.runCli(["machine-code"]);
+ expect(text.exitCode).toBe(0);
+ expect(text.stdout).toContain("Code: K7QP-2M4X");
+ expect(text.stdout).toContain("Server: https://sawyer.getbb.app");
+ expect(text.stdout).toContain("Apex: https://getbb.app");
+ expect(text.stdout).toContain("in about 10 min");
+ expect(text.stdout).toContain("Add mobile device");
+
+ const json = await harness.runCli(["machine-code", "--json"]);
+ expect(json.exitCode).toBe(0);
+ const parsed = JSON.parse(json.stdout ?? "") as Record;
+ expect(parsed).toEqual({
+ code: "K7QP-2M4X",
+ serverUrl: "https://sawyer.getbb.app",
+ apex: "https://getbb.app",
+ expiresAt: expect.any(Number),
+ });
+ expect(parsed.expiresAt as number).toBeGreaterThanOrEqual(before + 600_000);
+ // Minted through the apex with the server's own pairing credential.
+ const call = fetchMock.mock.calls.find(
+ ([input]) =>
+ String(input) === "https://getbb.app/api/connect/machine-code",
+ );
+ expect(call?.[1]).toEqual({
+ method: "POST",
+ headers: { "x-bb-connect-machine": "bbcred_live" },
+ });
+ });
+
+ it("machine-code explains the account machine limit and names the dashboard", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/api/connect/redeem")) {
+ return new Response(
+ JSON.stringify({ credential: "bbcred_live", handle: "sawyer" }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/api/connect/machine-code")) {
+ return new Response(JSON.stringify({ error: "machine-limit" }), {
+ status: 409,
+ });
+ }
+ return new Response("not found", { status: 404 });
+ }),
+ );
+ const { harness } = await loadCli();
+ await harness.runCli([
+ "--code",
+ "ABCD",
+ "--server",
+ "https://sawyer.getbb.app",
+ ]);
+ const result = await harness.runCli(["machine-code"]);
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain("machine limit");
+ expect(result.stderr).toContain("https://getbb.app/dashboard");
+ expect(result.stderr).not.toContain("machine_limit");
+ });
+
it("expose / shares / unexpose happy path", async () => {
vi.stubGlobal(
"fetch",
diff --git a/plugins/connect/src/rpc.ts b/plugins/connect/src/rpc.ts
index 2ef1f0934d..2f7312bb6b 100644
--- a/plugins/connect/src/rpc.ts
+++ b/plugins/connect/src/rpc.ts
@@ -98,6 +98,13 @@ const desktopSessionSchema: z.ZodType = z
})
.strict();
+const mobilePairingSchema = z
+ .object({
+ /** True when the `mobileApp` experiment is on (Settings → Experiments). */
+ enabled: z.boolean(),
+ })
+ .strict();
+
const machineCodeSchema: z.ZodType = z
.object({
code: z.string(),
@@ -128,6 +135,7 @@ export const connectRpcContract = defineRpcContract({
output: listAccountServersResultSchema,
},
createDesktopSession: { input: z.null(), output: desktopSessionSchema },
+ mobilePairing: { input: z.null(), output: mobilePairingSchema },
createMachineCode: { input: z.null(), output: machineCodeSchema },
revokeMachine: {
input: revokeMachineInputSchema,
@@ -137,9 +145,22 @@ export const connectRpcContract = defineRpcContract({
export type ConnectRpcHandlers = PluginRpcHandlers;
+/**
+ * Mobile pairing (the "Add mobile device" card and `bb connect machine-code`)
+ * ships behind the user-toggled `mobileApp` experiment until the app is
+ * generally available. The gate is read at call time so a toggle applies
+ * without a plugin reload. It covers only those two mobile surfaces: the
+ * `createMachineCode` rpc itself stays open because the desktop app's own
+ * enrollment and the web "Add machine" dialog mint machine codes through it.
+ */
+export interface MobilePairingGate {
+ enabled(): Promise;
+}
+
export function createRpcHandlers(
tunnel: ConnectTunnel,
hostResolver: ShareHostResolver,
+ mobilePairing: MobilePairingGate,
): ConnectRpcHandlers {
return {
async pair(args) {
@@ -202,6 +223,9 @@ export function createRpcHandlers(
throw error;
}
},
+ async mobilePairing() {
+ return { enabled: await mobilePairing.enabled() };
+ },
async createMachineCode() {
try {
return await tunnel.createMachineCode();
diff --git a/plugins/connect/src/server.ts b/plugins/connect/src/server.ts
index 4d1c4f324f..bdaf95bffb 100644
--- a/plugins/connect/src/server.ts
+++ b/plugins/connect/src/server.ts
@@ -1,7 +1,11 @@
import type { BbPluginApi } from "@get-bb/plugin-sdk";
import { registerConnectCli } from "./cli.js";
import { createKvCredentialStore } from "./credential.js";
-import { connectRpcContract, createRpcHandlers } from "./rpc.js";
+import {
+ connectRpcContract,
+ createRpcHandlers,
+ type MobilePairingGate,
+} from "./rpc.js";
import { ShareRegistry } from "./shares.js";
import { ConnectTunnel } from "./tunnel.js";
import { ShareHostResolver } from "./hosts.js";
@@ -45,8 +49,17 @@ export default async function plugin(bb: BbPluginApi) {
bb.realtime.publish(CONNECT_REALTIME_CHANNEL, status),
});
- bb.rpc.register(connectRpcContract, createRpcHandlers(tunnel, hostResolver));
- registerConnectCli({ bb, tunnel, hostResolver });
+ // Experiments are server-owned settings; the plugin reads them through its
+ // loopback SDK binding rather than through a dedicated plugin API.
+ const mobilePairing: MobilePairingGate = {
+ enabled: async () => (await bb.sdk.system.config()).experiments.mobileApp,
+ };
+
+ bb.rpc.register(
+ connectRpcContract,
+ createRpcHandlers(tunnel, hostResolver, mobilePairing),
+ );
+ registerConnectCli({ bb, tunnel, hostResolver, mobilePairing });
bb.agents.contributeInstructions(() => {
const status = tunnel.status();
diff --git a/plugins/secrets/app.tsx b/plugins/secrets/app.tsx
index 9442bab0f6..b282f567c3 100644
--- a/plugins/secrets/app.tsx
+++ b/plugins/secrets/app.tsx
@@ -13,6 +13,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
+ SECRET_REQUEST_RENDERER_ID,
secretRequestPayloadSchema,
secretRequestResponseSchema,
} from "./src/contracts.js";
@@ -216,7 +217,7 @@ function SecretRequestInteraction({
export default definePluginApp((app) => {
app.slots.pendingInteraction({
- id: "secret-request",
+ id: SECRET_REQUEST_RENDERER_ID,
component: SecretRequestInteraction,
});
});
diff --git a/plugins/secrets/package.json b/plugins/secrets/package.json
index 84e95928d8..a2f18ea5b3 100644
--- a/plugins/secrets/package.json
+++ b/plugins/secrets/package.json
@@ -24,6 +24,7 @@
"test": "vitest run --config vitest.config.ts"
},
"dependencies": {
+ "@bb/plugin-interaction-contracts": "workspace:*",
"@bb/shared-ui": "workspace:*",
"@hugeicons/core-free-icons": "^4.1.3",
"@hugeicons/react": "^1.1.6",
diff --git a/plugins/secrets/src/contracts.ts b/plugins/secrets/src/contracts.ts
index 718c24116d..b215440808 100644
--- a/plugins/secrets/src/contracts.ts
+++ b/plugins/secrets/src/contracts.ts
@@ -1,32 +1,12 @@
-import { z } from "zod";
-
-export const secretNameSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/u);
-
-export const secretRequestPayloadSchema = z.object({
- purpose: z.string().min(1).nullable(),
- destination: z.object({ kind: z.literal("dotenv"), path: z.string().min(1) }),
- fields: z
- .array(
- z.object({
- name: secretNameSchema,
- description: z.string().min(1).nullable(),
- }),
- )
- .min(1),
-});
-export type SecretRequestPayload = z.infer;
-
-const secretValueSchema = z
- .string()
- .min(1)
- .max(16 * 1024)
- .refine((value) => !value.includes("\n") && !value.includes("\r"), {
- message: "Secret values must be single-line strings",
- })
- .refine((value) => !value.includes("\0"), {
- message: "Secret values must not contain NUL",
- });
-
-export const secretRequestResponseSchema = z.object({
- values: z.record(secretNameSchema, secretValueSchema),
-});
+// The secret-request payload/response contract lives in
+// @bb/plugin-interaction-contracts so clients that cannot run this plugin's
+// React DOM bundle (the native app) can render the same secure form.
+// Re-exported here so the plugin's own modules keep one import path.
+export {
+ SECRET_REQUEST_RENDERER_ID,
+ secretNameSchema,
+ secretRequestPayloadSchema,
+ secretRequestResponseSchema,
+ type SecretRequestPayload,
+ type SecretRequestResponse,
+} from "@bb/plugin-interaction-contracts";
diff --git a/plugins/secrets/src/server.ts b/plugins/secrets/src/server.ts
index 84ce296067..b284f5ba31 100644
--- a/plugins/secrets/src/server.ts
+++ b/plugins/secrets/src/server.ts
@@ -5,7 +5,11 @@ import type {
PluginCliResult,
} from "@get-bb/plugin-sdk";
import { z } from "zod";
-import { secretNameSchema, secretRequestResponseSchema } from "./contracts.js";
+import {
+ SECRET_REQUEST_RENDERER_ID,
+ secretNameSchema,
+ secretRequestResponseSchema,
+} from "./contracts.js";
import { assertNoDuplicateAssignments, reconcileDotenv } from "./dotenv.js";
interface ParsedRequest {
@@ -176,7 +180,7 @@ async function runRequest(
const result = await bb.ui.requestInput(
{
threadId: ctx.threadId,
- rendererId: "secret-request",
+ rendererId: SECRET_REQUEST_RENDERER_ID,
title: `Add secrets to ${destinationPath}`,
payload: {
purpose: parsed.purpose,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 302eb7ed4d..1ea0205a83 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1783,6 +1783,28 @@ importers:
specifier: ^4.1.1
version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
+ packages/plugin-interaction-contracts:
+ dependencies:
+ zod:
+ specifier: 4.3.6
+ version: 4.3.6
+ devDependencies:
+ '@bb/tsconfig':
+ specifier: workspace:*
+ version: link:../tsconfig
+ '@types/node':
+ specifier: ^22.0.0
+ version: 22.19.10
+ typescript:
+ specifier: npm:@typescript/typescript6@^6.0.2
+ version: '@typescript/typescript6@6.0.2'
+ typescript-7:
+ specifier: npm:typescript@^7.0.2
+ version: typescript@7.0.2
+ vitest:
+ specifier: ^4.1.1
+ version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))
+
packages/plugin-registry:
devDependencies:
'@bb/plugin-build':
@@ -2461,6 +2483,9 @@ importers:
plugins/ask-user-question:
dependencies:
+ '@bb/plugin-interaction-contracts':
+ specifier: workspace:*
+ version: link:../../packages/plugin-interaction-contracts
'@bb/shared-ui':
specifier: workspace:*
version: link:../../packages/shared-ui
@@ -3131,6 +3156,9 @@ importers:
plugins/secrets:
dependencies:
+ '@bb/plugin-interaction-contracts':
+ specifier: workspace:*
+ version: link:../../packages/plugin-interaction-contracts
'@bb/shared-ui':
specifier: workspace:*
version: link:../../packages/shared-ui
diff --git a/tests/integration/helpers/harness.ts b/tests/integration/helpers/harness.ts
index 786645c573..3ce31baad0 100644
--- a/tests/integration/helpers/harness.ts
+++ b/tests/integration/helpers/harness.ts
@@ -107,6 +107,17 @@ export interface IntegrationHarness {
export interface CreateHarnessOptions {
adapterFactory?: ProviderAdapterFactory;
+ /**
+ * Bind the server to a fixed port instead of an ephemeral one. Long-lived
+ * harness backends (mobile e2e) need a stable URL for the app under test.
+ */
+ serverPort?: number;
+ /**
+ * Bind host. Defaults to loopback. `0.0.0.0` lets a physical phone on the
+ * same network reach the harness server; the client base URL stays on
+ * 127.0.0.1 either way.
+ */
+ bindHost?: "127.0.0.1" | "0.0.0.0";
}
export type WithHarnessCallback = (
@@ -344,8 +355,8 @@ async function startIntegrationServer(
// 127.0.0.1 too. If we leave the host unspecified, this server can end
// up on ::1 while another local process owns 127.0.0.1 on the same
// port, and the client will hit that other process instead.
- hostname: TEST_SERVER_HOST,
- port: 0,
+ hostname: options.bindHost ?? TEST_SERVER_HOST,
+ port: options.serverPort ?? 0,
fetch: app.fetch,
},
(info) => {
diff --git a/tests/integration/mobile-e2e/backend.ts b/tests/integration/mobile-e2e/backend.ts
new file mode 100644
index 0000000000..dbd9ba4672
--- /dev/null
+++ b/tests/integration/mobile-e2e/backend.ts
@@ -0,0 +1,334 @@
+// Long-lived harness backend for the mobile app's Maestro flows.
+//
+// Starts the in-process integration server + host daemon with the fake
+// provider adapter (user questions enabled) on a fixed port, seeds a project
+// with a few threads, prints the connection details as JSON on stdout, and
+// keeps running until SIGINT/SIGTERM.
+//
+// Usage (from the repo root):
+// pnpm --filter @bb/integration-tests e2e:mobile-backend
+// BB_MOBILE_E2E_PORT=41999 BB_MOBILE_E2E_BIND_HOST=0.0.0.0 pnpm ... (physical phone)
+//
+// The iOS Simulator shares the Mac loopback, so the app can use
+// http://127.0.0.1: directly. The Android emulator uses 10.0.2.2.
+//
+// SECURITY: BB_MOBILE_E2E_BIND_HOST=0.0.0.0 exposes the unauthenticated
+// harness server and a real host daemon (terminal sessions and file reads as
+// your user) to everyone on the network, the same exposure as
+// `bb --server-bind-host 0.0.0.0`. Use it only on a trusted network and stop
+// the backend when you are done.
+import { networkInterfaces } from "node:os";
+import { createFakeAdapter } from "@bb/agent-runtime/test";
+import type { PendingInteraction } from "@bb/domain";
+import { createIntegrationHarness } from "../helpers/harness.js";
+import type { IntegrationHarness } from "../helpers/harness.js";
+import {
+ createProjectFixture,
+ createReadyHostThread,
+} from "../helpers/fixtures.js";
+import {
+ listThreadInteractions,
+ resolveThreadInteraction,
+ sendTextMessage,
+} from "../helpers/api.js";
+import { waitForThreadStatus } from "../helpers/assertions.js";
+
+const DEFAULT_PORT = 41999;
+
+function readPort(): number {
+ const raw = process.env.BB_MOBILE_E2E_PORT;
+ if (!raw) return DEFAULT_PORT;
+ const parsed = Number.parseInt(raw, 10);
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
+ throw new Error(`Invalid BB_MOBILE_E2E_PORT: ${raw}`);
+ }
+ return parsed;
+}
+
+function readBindHost(): "127.0.0.1" | "0.0.0.0" {
+ const raw = process.env.BB_MOBILE_E2E_BIND_HOST;
+ if (raw === undefined || raw === "127.0.0.1") return "127.0.0.1";
+ if (raw === "0.0.0.0") return "0.0.0.0";
+ throw new Error(`Invalid BB_MOBILE_E2E_BIND_HOST: ${raw}`);
+}
+
+/** Non-internal IPv4 addresses a phone on the same network could reach. */
+function listLanIpv4Addresses(): string[] {
+ return Object.values(networkInterfaces())
+ .flatMap((entries) => entries ?? [])
+ .filter((entry) => entry.family === "IPv4" && !entry.internal)
+ .map((entry) => entry.address);
+}
+
+/** Mirrors the server's wildcard-bind warning (apps/server start-server). */
+function warnWildcardBind(port: number): void {
+ const lanUrls = listLanIpv4Addresses().map(
+ (address) => `http://${address}:${port}`,
+ );
+ process.stderr.write(
+ [
+ "mobile-e2e backend: SECURITY WARNING: binding on 0.0.0.0. The harness",
+ "server is unauthenticated and runs a real host daemon that permits",
+ "command execution (terminal sessions) and file reads as your user.",
+ "Use BB_MOBILE_E2E_BIND_HOST=0.0.0.0 only behind a trusted network",
+ "boundary and stop the backend when you are done.",
+ ].join(" ") +
+ "\n" +
+ (lanUrls.length > 0
+ ? `mobile-e2e backend: point the phone at ${lanUrls.join(" or ")}\n`
+ : ""),
+ );
+}
+
+const TURN_TIMEOUT_MS = 15_000;
+const INTERACTION_POLL_INTERVAL_MS = 100;
+
+/** First pending interaction of `threadId`, polling until one appears. */
+async function waitForPendingInteraction(
+ harness: IntegrationHarness,
+ threadId: string,
+ timeoutMs = TURN_TIMEOUT_MS,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ const interactions = await listThreadInteractions(harness.api, threadId);
+ const pending = interactions.find(
+ (interaction) => interaction.status === "pending",
+ );
+ if (pending) return pending;
+ if (Date.now() >= deadline) {
+ throw new Error(
+ `Timed out waiting for a pending interaction on thread ${threadId}`,
+ );
+ }
+ await new Promise((resolve) =>
+ setTimeout(resolve, INTERACTION_POLL_INTERVAL_MS),
+ );
+ }
+}
+
+/** Sends one message and waits for the turn to finish. */
+async function runTurn(
+ harness: IntegrationHarness,
+ threadId: string,
+ text: string,
+): Promise {
+ await sendTextMessage(harness.api, threadId, { text });
+ await waitForThreadStatus(harness.api, threadId, "idle", TURN_TIMEOUT_MS);
+}
+
+// ≥60 lines of markdown with a heading, a code block, a table, a list, and a
+// link so every Phase 4a renderer has content to show. Must not contain the
+// fake provider's control tokens (`approve:`, `call_tool:`, `ask_user`,
+// `delay:`) anywhere as whitespace-delimited words.
+const LONG_MARKDOWN_MESSAGE = [
+ "# Release checklist overview",
+ "",
+ "This message exercises the markdown renderer end to end. It is long on",
+ "purpose so the timeline has to scroll, and it mixes every block type the",
+ "mobile renderer supports.",
+ "",
+ "## Goals",
+ "",
+ "- Ship the native timeline with parity for common rows.",
+ "- Keep the delta merge cheap while a turn streams.",
+ "- Verify paging, the unread divider, and the table of contents.",
+ "",
+ "## Steps",
+ "",
+ "1. Build the dev client once.",
+ "2. Start Metro against the harness backend.",
+ "3. Run the Maestro flows.",
+ "",
+ "## Commands",
+ "",
+ "```bash",
+ "pnpm exec turbo run typecheck lint test --filter=@bb/mobile",
+ "cd apps/mobile && pnpm e2e:ios",
+ "xcrun simctl io booted screenshot /tmp/timeline.png",
+ "```",
+ "",
+ "## Rows covered",
+ "",
+ "| Row kind | Source | Notes |",
+ "| --- | --- | --- |",
+ "| conversation | user + assistant | markdown bodies |",
+ "| command | tool-call token | tool call with args |",
+ "| approval | approval token | allowed once |",
+ "| question | question token | left pending |",
+ "",
+ "## Reference",
+ "",
+ "See the [bb docs](https://docs.getbb.app) for the server contract and the",
+ "plan in `plans/bb-mobile-expo.md` for the phase breakdown.",
+ "",
+ "## Notes",
+ "",
+ "Paragraph one of the notes section. It has enough words to wrap on a",
+ "phone so line breaking inside paragraphs gets exercised as well.",
+ "",
+ "Paragraph two mentions `inline code`, **bold text**, and _emphasis_ so",
+ "the inline renderers get a look too.",
+ "",
+ "> A blockquote with a single line of advice: keep the rows flat.",
+ "",
+ "### Sub-heading one",
+ "",
+ "- nested list level one",
+ " - nested list level two",
+ " - another level-two item",
+ "- back to level one",
+ "",
+ "### Sub-heading two",
+ "",
+ "Final paragraph. If you can read this on the device, the long message",
+ "scrolled into view correctly.",
+ "",
+ "Trailing line one.",
+ "Trailing line two.",
+ "Trailing line three.",
+ "Trailing line four.",
+].join("\n");
+
+async function main(): Promise {
+ const bindHost = readBindHost();
+ const serverPort = readPort();
+ if (bindHost === "0.0.0.0") warnWildcardBind(serverPort);
+ const harness = await createIntegrationHarness({
+ adapterFactory: () =>
+ createFakeAdapter({ supportsNativeUserQuestion: true }),
+ bindHost,
+ serverPort,
+ });
+
+ const shutdown = async (signal: string) => {
+ process.stderr.write(`mobile-e2e backend: ${signal}, shutting down\n`);
+ await harness.cleanup();
+ process.exit(0);
+ };
+ process.on("SIGINT", () => void shutdown("SIGINT"));
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
+
+ const project = await createProjectFixture(harness, {
+ name: "Mobile E2E Project",
+ });
+
+ // Thread 1: a completed exchange the app can render read-only.
+ const completed = await createReadyHostThread(harness, {
+ projectId: project.id,
+ title: "Completed thread",
+ workspace: { type: "unmanaged", path: null },
+ });
+ await sendTextMessage(harness.api, completed.thread.id, {
+ text: "Hello from the seed",
+ });
+ await waitForThreadStatus(harness.api, completed.thread.id, "idle", 15_000);
+
+ // Thread 2: idle, for the app to send into (fake adapter echoes
+ // `Response to: ...`, honors `delay:` and `ask_user`).
+ const idle = await createReadyHostThread(harness, {
+ projectId: project.id,
+ title: "Idle thread",
+ workspace: { type: "unmanaged", path: null },
+ });
+
+ // Thread 3: several turns so the timeline has content for every Phase 4a
+ // row renderer. The `ask_user` turn goes last and is left pending on
+ // purpose (the thread stays "needs input").
+ const rich = await createReadyHostThread(harness, {
+ projectId: project.id,
+ title: "Rich thread",
+ workspace: { type: "unmanaged", path: null },
+ });
+ const richId = rich.thread.id;
+ await runTurn(harness, richId, "Hello rich thread, first message");
+ await runTurn(harness, richId, "delay:300 second message");
+ await runTurn(harness, richId, "call_tool:my_test_tool");
+ // Approval: the fake adapter blocks until the command approval resolves.
+ await sendTextMessage(harness.api, richId, {
+ text: "approve:command echo hi",
+ });
+ const approval = await waitForPendingInteraction(harness, richId);
+ await resolveThreadInteraction({
+ api: harness.api,
+ threadId: richId,
+ interactionId: approval.id,
+ resolution: { decision: "allow_once", grantedPermissions: null },
+ });
+ await waitForThreadStatus(harness.api, richId, "idle", TURN_TIMEOUT_MS);
+ await runTurn(harness, richId, LONG_MARKDOWN_MESSAGE);
+ // Left pending: the question row + "Needs input" state.
+ await sendTextMessage(harness.api, richId, { text: "ask_user" });
+ await waitForPendingInteraction(harness, richId);
+ // Sending through the API marks the thread read as the sender; the
+ // timeline flow expects to open this thread unread (divider at the top).
+ const unreadResponse = await harness.api.threads[":id"].unread.$post({
+ param: { id: richId },
+ });
+ if (unreadResponse.status !== 200) {
+ throw new Error(
+ `mark rich thread unread failed: ${unreadResponse.status} ${await unreadResponse.text()}`,
+ );
+ }
+
+ // Thread 4: started on behalf of the idle thread (a fork seed anchor), so
+ // the first row is the generated "Forked from Idle thread" conversation row
+ // rather than a user bubble; one follow-up turn keeps it idle.
+ const rowsThreadResponse = await harness.api.threads.$post({
+ json: {
+ environment: { type: "reuse", environmentId: completed.environment.id },
+ input: [
+ {
+ type: "text",
+ text: "Worker finished: all checks pass.\nThe summary is in the next message.",
+ mentions: [],
+ },
+ ],
+ origin: "app",
+ model: "fake-model",
+ parentThreadId: idle.thread.id,
+ projectId: project.id,
+ providerId: "fake",
+ title: "Rows thread",
+ startedOnBehalfOf: { initiator: "agent", senderThreadId: idle.thread.id },
+ originKind: "fork",
+ },
+ });
+ if (rowsThreadResponse.status !== 201) {
+ throw new Error(
+ `create rows thread failed: ${rowsThreadResponse.status} ${await rowsThreadResponse.text()}`,
+ );
+ }
+ const rowsThread = (await rowsThreadResponse.json()) as { id: string };
+ await waitForThreadStatus(
+ harness.api,
+ rowsThread.id,
+ "idle",
+ TURN_TIMEOUT_MS,
+ );
+ await runTurn(harness, rowsThread.id, "Thanks, proceed.");
+
+ const details = {
+ hostId: harness.hostId,
+ projectId: project.id,
+ serverUrl: harness.serverUrl,
+ threads: {
+ completed: completed.thread.id,
+ idle: idle.thread.id,
+ rich: richId,
+ rows: rowsThread.id,
+ },
+ };
+ process.stdout.write(`${JSON.stringify(details)}\n`);
+ process.stderr.write(
+ `mobile-e2e backend ready at ${harness.serverUrl} (Ctrl-C to stop)\n`,
+ );
+
+ // Keep the process alive.
+ await new Promise(() => {});
+}
+
+main().catch((error) => {
+ process.stderr.write(`mobile-e2e backend failed: ${String(error)}\n`);
+ process.exit(1);
+});
diff --git a/tests/integration/mobile-e2e/connect-stub.ts b/tests/integration/mobile-e2e/connect-stub.ts
new file mode 100644
index 0000000000..8e144af5a0
--- /dev/null
+++ b/tests/integration/mobile-e2e/connect-stub.ts
@@ -0,0 +1,812 @@
+// Stub bb connect apex + gate for the mobile app's Maestro flows.
+//
+// The real topology is `https://getbb.app` (apex: redeems pairing codes) and
+// `https://.getbb.app` (gate: mints the desktop-session cookie, lists
+// the account's servers, and proxies everything else to the bb server behind
+// the tunnel only when a session cookie is present). This stub plays both on
+// one TLS port in front of the harness backend (`e2e:mobile-backend`):
+//
+// apex https://localhost: POST /api/connect/redeem-machine
+// gate https://.localhost: POST /api/connect/desktop-session
+// GET /api/connect/servers
+// everything else → upstream bb
+// (HTTP + WebSocket), 401 HTML
+// sign-in page without a session
+//
+// TLS is required: iOS App Transport Security refuses plain http to a
+// qualified hostname and `@bb/connect-client` insists that the server lives
+// under the apex (`