Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
experiments: {
claudeCodeMockCliTraffic: false,
editMessages: false,
mobileApp: false,
newOnboarding: false,
providerSessionReaping: false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ vi.mock("@/hooks/queries/system-queries", () => ({
experiments: {
claudeCodeMockCliTraffic: false,
editMessages: false,
mobileApp: false,
newOnboarding: false,
providerSessionReaping: false,
},
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/lib/system-config-atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const unavailableSystemConfig: SystemConfigResponse = {
experiments: {
claudeCodeMockCliTraffic: false,
editMessages: false,
mobileApp: false,
newOnboarding: false,
providerSessionReaping: false,
},
Expand Down
10 changes: 10 additions & 0 deletions apps/app/src/views/SettingsView.experiments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ExperimentsSettingsSection } from "./SettingsView";
afterEach(cleanup);

function renderSection(overrides?: {
onMobileAppEnabledChange?: (enabled: boolean) => void;
onNewOnboardingEnabledChange?: (enabled: boolean) => void;
onProviderSessionReapingEnabledChange?: (enabled: boolean) => void;
}) {
Expand All @@ -14,10 +15,12 @@ function renderSection(overrides?: {
claudeCodeMockCliTrafficEnabled={false}
disabled={false}
editMessagesEnabled={false}
mobileAppEnabled={false}
newOnboardingEnabled={false}
providerSessionReapingEnabled={false}
onClaudeCodeMockCliTrafficEnabledChange={vi.fn()}
onEditMessagesEnabledChange={vi.fn()}
onMobileAppEnabledChange={overrides?.onMobileAppEnabledChange ?? vi.fn()}
onNewOnboardingEnabledChange={
overrides?.onNewOnboardingEnabledChange ?? vi.fn()
}
Expand All @@ -36,6 +39,13 @@ describe("ExperimentsSettingsSection", () => {
expect(onChange).toHaveBeenCalledWith(true);
});

it("reports mobile app changes", () => {
const onChange = vi.fn();
renderSection({ onMobileAppEnabledChange: onChange });
fireEvent.click(screen.getByLabelText("Mobile app"));
expect(onChange).toHaveBeenCalledWith(true);
});

it("reports idle provider session release changes", () => {
const onChange = vi.fn();
renderSection({ onProviderSessionReapingEnabledChange: onChange });
Expand Down
7 changes: 7 additions & 0 deletions apps/app/src/views/SettingsView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ function ExperimentsStory() {
}
disabled={false}
editMessagesEnabled={state.experiments.editMessages}
mobileAppEnabled={state.experiments.mobileApp}
newOnboardingEnabled={state.experiments.newOnboarding}
providerSessionReapingEnabled={state.experiments.providerSessionReaping}
onClaudeCodeMockCliTrafficEnabledChange={(enabled) =>
Expand All @@ -349,6 +350,12 @@ function ExperimentsStory() {
editMessages: enabled,
}))
}
onMobileAppEnabledChange={(enabled) =>
state.setExperiments((current) => ({
...current,
mobileApp: enabled,
}))
}
onNewOnboardingEnabledChange={(enabled) =>
state.setExperiments((current) => ({
...current,
Expand Down
24 changes: 24 additions & 0 deletions apps/app/src/views/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,12 @@ export interface ExperimentsSettingsSectionProps {
disabled: boolean;
claudeCodeMockCliTrafficEnabled: boolean;
editMessagesEnabled: boolean;
mobileAppEnabled: boolean;
newOnboardingEnabled: boolean;
providerSessionReapingEnabled: boolean;
onClaudeCodeMockCliTrafficEnabledChange: (enabled: boolean) => void;
onEditMessagesEnabledChange: (enabled: boolean) => void;
onMobileAppEnabledChange: (enabled: boolean) => void;
onNewOnboardingEnabledChange: (enabled: boolean) => void;
onProviderSessionReapingEnabledChange: (enabled: boolean) => void;
}
Expand Down Expand Up @@ -996,17 +998,20 @@ export function ProviderSettingsSection({

const CLAUDE_CODE_MOCK_CLI_TRAFFIC_EXPERIMENT_LABEL = "Mock CLI Traffic";
const EDIT_MESSAGES_EXPERIMENT_LABEL = "Edit messages";
const MOBILE_APP_EXPERIMENT_LABEL = "Mobile app";
const NEW_ONBOARDING_EXPERIMENT_LABEL = "New onboarding";
const PROVIDER_SESSION_REAPING_EXPERIMENT_LABEL =
"Idle provider session release";
export function ExperimentsSettingsSection({
claudeCodeMockCliTrafficEnabled,
disabled,
editMessagesEnabled,
mobileAppEnabled,
newOnboardingEnabled,
providerSessionReapingEnabled,
onClaudeCodeMockCliTrafficEnabledChange,
onEditMessagesEnabledChange,
onMobileAppEnabledChange,
onNewOnboardingEnabledChange,
onProviderSessionReapingEnabledChange,
}: ExperimentsSettingsSectionProps) {
Expand Down Expand Up @@ -1041,6 +1046,18 @@ export function ExperimentsSettingsSection({
/>
</SettingsWithControl>

<SettingsWithControl
label={MOBILE_APP_EXPERIMENT_LABEL}
description="Pair the bb mobile app over bb connect: shows Add mobile device under Remote access and enables bb connect machine-code."
>
<Switch
checked={mobileAppEnabled}
disabled={disabled}
onCheckedChange={onMobileAppEnabledChange}
aria-label={MOBILE_APP_EXPERIMENT_LABEL}
/>
</SettingsWithControl>

<SettingsWithControl
label={NEW_ONBOARDING_EXPERIMENT_LABEL}
description="Enable the new first-run guide for agent setup and project selection."
Expand Down Expand Up @@ -1232,6 +1249,13 @@ export function SettingsView() {
editMessages: enabled,
})
}
mobileAppEnabled={experiments.mobileApp}
onMobileAppEnabledChange={(enabled) =>
updateExperimentsMutation.mutate({
...experiments,
mobileApp: enabled,
})
}
newOnboardingEnabled={experiments.newOnboarding}
onNewOnboardingEnabledChange={(enabled) =>
updateExperimentsMutation.mutate({
Expand Down
6 changes: 6 additions & 0 deletions apps/connect/src/tunnel-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export interface Env {
BETTER_AUTH_SECRET: string;
ACCOUNT_APP_URL?: string;
CLOUD_DEV?: string;
/**
* Android signing-cert SHA-256 fingerprints for `/.well-known/assetlinks.json`
* (comma-separated). Unset → the file serves an empty list (iOS universal
* links are unaffected).
*/
ASSETLINKS_SHA256_FINGERPRINTS?: string;
}

const TUNNEL_TAG = "tunnel";
Expand Down
112 changes: 112 additions & 0 deletions apps/connect/src/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,118 @@ describe("machine gate auth", () => {
);
});

describe("bb mobile app-link association files", () => {
beforeEach(() => {
vi.clearAllMocks();
// No cookie, no machine header: these must never reach the session gate.
mockParseCookie.mockReturnValue(null);
mockResolveLabel.mockResolvedValue(resolvedServer());
});

afterEach(() => {
vi.clearAllMocks();
});

it.each([
"/.well-known/apple-app-site-association",
"/.well-known/assetlinks.json",
])(
"serves %s on a bare label without a session and without proxying",
async (path) => {
const { env, ctx, captured } = makeEnv(() => new Response("origin"));
const response = await worker.fetch(
visitorRequest("sawyer.getbb.app", path),
env as never,
ctx,
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
expect(captured).toHaveLength(0);
expect(mockResolveLabel).not.toHaveBeenCalled();
expect(mockVerifySession).not.toHaveBeenCalled();
},
);

it("serves the AASA on bare labels that do not resolve yet (Apple fetches anonymously before a claim)", async () => {
mockResolveLabel.mockResolvedValue(null);
const { env, ctx, captured } = makeEnv(() => new Response("origin"));
const unknown = await worker.fetch(
visitorRequest(
"nobody-here.getbb.app",
"/.well-known/apple-app-site-association",
),
env as never,
ctx,
);
expect(unknown.status).toBe(200);
const body = (await unknown.json()) as {
applinks: { details: { appIDs: string[] }[] };
};
expect(body.applinks.details[0]?.appIDs).toEqual([
"9QCU24SXK5.app.getbb.mobile",
]);
expect(captured).toHaveLength(0);
});

it.each([
"/.well-known/apple-app-site-association",
"/.well-known/assetlinks.json",
])(
"does not claim %s on share hosts — they front arbitrary local apps, so the file falls through to the session gate",
async (path) => {
const { env, ctx, captured } = makeEnv(() => new Response("origin"));
const share = await worker.fetch(
visitorRequest("sawyer--8000.getbb.app", path),
env as never,
ctx,
);
// Anonymous (Apple CDN / Android) fetch → 401 sign-in page, i.e. no
// association for `<label>--<port>` hosts; never proxied without a session.
expect(share.status).toBe(401);
expect(share.headers.get("content-type")).not.toBe("application/json");
expect(captured).toHaveLength(0);
},
);

it("reads Android fingerprints from the env and serves an empty list otherwise", async () => {
const { env, ctx } = makeEnv(() => new Response("origin"));
const empty = await worker.fetch(
visitorRequest("sawyer.getbb.app", "/.well-known/assetlinks.json"),
env as never,
ctx,
);
const emptyBody = (await empty.json()) as {
target: { sha256_cert_fingerprints: string[] };
}[];
expect(emptyBody[0]?.target.sha256_cert_fingerprints).toEqual([]);

const withEnv = await worker.fetch(
visitorRequest("sawyer.getbb.app", "/.well-known/assetlinks.json"),
{ ...env, ASSETLINKS_SHA256_FINGERPRINTS: "aa:bb,cc:dd" } as never,
ctx,
);
const withEnvBody = (await withEnv.json()) as {
target: { package_name: string; sha256_cert_fingerprints: string[] };
}[];
expect(withEnvBody[0]?.target.package_name).toBe("app.getbb.mobile");
expect(withEnvBody[0]?.target.sha256_cert_fingerprints).toEqual([
"AA:BB",
"CC:DD",
]);
});

it("leaves other .well-known paths to the session gate", async () => {
const { env, ctx, captured } = makeEnv(() => new Response("origin"));
const response = await worker.fetch(
visitorRequest("sawyer.getbb.app", "/.well-known/openid-configuration"),
env as never,
ctx,
);
expect(response.status).toBe(401);
expect(captured).toHaveLength(0);
});
});

describe("gate worker share hosts", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
23 changes: 21 additions & 2 deletions apps/connect/src/worker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { drizzle } from "drizzle-orm/d1";
import { RESERVED_HANDLES, parseVisitorHost, schema } from "@bb/connect-db";
import {
RESERVED_HANDLES,
handleAppLinkAssociationRequest,
parseVisitorHost,
schema,
} from "@bb/connect-db";
import { TUNNEL_OFFLINE_HEADER, TunnelDO, type Env } from "./tunnel-do.js";
import {
parseCookie,
Expand Down Expand Up @@ -290,10 +295,24 @@ export default {
if (url.pathname === "/api/connect/machine-label") {
return handleAssignMachineLabel(request, env);
}

const host = resolveConnectRequestHost(request.headers, runtime);
const parsed = parseVisitorHost(host, env.BASE_DOMAIN);
if (!parsed) return text("bb connect: unknown host\n", 404);
// bb mobile universal / app links: Apple's CDN and Android fetch the
// association files anonymously from `https://<label>.getbb.app`, so
// bare labels answer here — before label resolution (Apple may fetch
// before the label is claimed) and the session gate, never proxied to
// the tunnel, never redirected. Share hosts (`<label>--<port>`) front
// arbitrary local apps, not a bb server, so they must not claim
// `/threads/*` & co for the app: they fall through to the normal gate
// like any other path (401 for Apple's anonymous fetch → no association).
if (parsed.target === null) {
const appLinks = handleAppLinkAssociationRequest(
{ method: request.method, url: url.toString() },
env,
);
if (appLinks) return appLinks;
}
// The base label is now ANY server's subdomain (the account handle names the
// primary bb; additional bbs claim their own labels), not just a profile
// handle. `target` (a port) rides along for share hosts, nested per-bb.
Expand Down
3 changes: 3 additions & 0 deletions apps/connect/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
// Secret: wrangler secret put BETTER_AUTH_SECRET [--env staging]
// (must equal bb-web's BETTER_AUTH_SECRET — the gate verifies the
// session cookie's HMAC with it)
// Optional var: ASSETLINKS_SHA256_FINGERPRINTS — comma-separated Android
// signing-cert fingerprints for /.well-known/assetlinks.json (bb
// mobile app links). Unset until the Android app is signed.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "bb-connect",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/scripts/smoke-packaged-app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ async function startSmokeServer({
dataDir,
experiments: {
claudeCodeMockCliTraffic: false,
mobileApp: false,
newOnboarding: false,
providerSessionReaping: false,
},
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/test/preload-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ async function startDesktopSmokeServer(
experiments: {
claudeCodeMockCliTraffic: false,
editMessages: false,
mobileApp: false,
newOnboarding: false,
providerSessionReaping: false,
},
Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/request-context.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { getConnInfo } from "@hono/node-server/conninfo";
import {
APP_SURFACE_HEADER_NAME,
parseAppSurface,
parseRequestAppSurface,
type AppSurface,
type RequestAppSurface,
} from "@bb/config/app-surface";
import type { Context } from "hono";

Expand Down Expand Up @@ -57,8 +58,9 @@ export function getGateMachineId(context: GateAuthHeaderReader): string | null {
export function resolveRequestAppSurface(
context: Context,
fallback: AppSurface,
): AppSurface {
): RequestAppSurface {
return (
parseAppSurface(context.req.header(APP_SURFACE_HEADER_NAME)) ?? fallback
parseRequestAppSurface(context.req.header(APP_SURFACE_HEADER_NAME)) ??
fallback
);
}
12 changes: 10 additions & 2 deletions apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,19 @@ isolated|reuse`, or anchor with `--source-seq-end`. Permission mode inherits
`bb connect servers` lists every bb on the paired account (handle,
name, url, live) so callers can discover siblings; `--json` includes
`selfHandle` for deduping this server. When you start a local server the user
should open remotely, expose the port and give them the share URL. Remote
should open remotely, expose the port and give them the share URL.
`bb connect machine-code` mints a one-time code (10 minutes, single use)
that pairs the bb mobile app with this bb (it needs the `mobileApp`
experiment: `bb settings experiment mobileApp true`): it prints the code, server URL,
connect apex, and expiry; `--json` returns `{code, serverUrl, apex,
expiresAt}`. The phone enrolls as a connect machine with its own revocable
credential (visible in the getbb.app dashboard). Settings → Remote access →
Add mobile device shows the same code as a QR. A machine-limit failure names
the dashboard so the user can revoke an unused device. Remote
access is owned by the builtin `connect` plugin: `bb plugin disable connect`
cuts it off entirely; with bb connect still enabled, `bb plugin enable
connect` restores the command. Plugins → Connect shows the current URL, QR
code, shared ports, re-pair form, and disconnect control.
code, mobile pairing, shared ports, re-pair form, and disconnect control.
- Add remote execution machines from Settings → Machines. Its one-line
installer stores the bb connect machine credential locally and configures
both the daemon protocol and agent-launched `bb` CLI to traverse the account
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,11 @@ every window and client sees the same value.
- Enable it with `bb settings experiment newOnboarding true`.
- Use `bb settings replay-onboarding` to enable the experiment and show the
agent and project setup guide again.

## Mobile app

- The `mobileApp` experiment defaults to false while the bb mobile app is in
early access.
- Enable it with `bb settings experiment mobileApp true`. It shows the
**Add mobile device** card under Settings → Remote access and enables
`bb connect machine-code`.
Loading
Loading