From c3189ac3e122e2c49946a40a0ee9aabb038c7c81 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 18 Sep 2026 21:58:40 -0700 Subject: [PATCH 1/3] Consolidate machine installation on bootstrap enrollment --- .bb/skills/verify-bb/INVENTORY.md | 1 - .../verify-bb/features/hosts-updates.md | 2 +- apps/cli/src/commands/machine.ts | 12 - apps/server/src/assets/install-machine.sh | 98 +----- .../test/app/install-machine-script.test.ts | 284 ++++++++---------- packages/sdk/src/areas/hosts.ts | 1 + .../src/templates/bb-guide-machines.md | 1 - .../skills/bb-cli/references/command-index.md | 5 +- .../bb-cli/references/thread-creation.md | 6 +- .../references/backend-sdk.md | 2 +- 10 files changed, 152 insertions(+), 260 deletions(-) diff --git a/.bb/skills/verify-bb/INVENTORY.md b/.bb/skills/verify-bb/INVENTORY.md index 6c96c125f5..bd1f597cc0 100644 --- a/.bb/skills/verify-bb/INVENTORY.md +++ b/.bb/skills/verify-bb/INVENTORY.md @@ -157,7 +157,6 @@ has been exercised or that every behavior has been understood. 1 source files. Recipes: [hosts-updates](features/hosts-updates.md). - `cli-command: apps/cli/src/commands/machine.ts: install ` -- `cli-command: apps/cli/src/commands/machine.ts: join-code` - `cli-command: apps/cli/src/commands/machine.ts: list` - `cli-command: apps/cli/src/commands/machine.ts: machine` - `cli-command: apps/cli/src/commands/machine.ts: provider-cli` diff --git a/.bb/skills/verify-bb/features/hosts-updates.md b/.bb/skills/verify-bb/features/hosts-updates.md index 6193f7ecb2..a548b5d19f 100644 --- a/.bb/skills/verify-bb/features/hosts-updates.md +++ b/.bb/skills/verify-bb/features/hosts-updates.md @@ -25,7 +25,7 @@ command’s `--help` before mutation. Use fresh browser snapshots for controls. | Feature | Drive | Observable success | | --- | --- | --- | | List, inspect, rename | Compare Machines with machine list/show; rename a disposable host and reload. | Stable host ID is preserved; name and connection state agree. | -| Pair and enroll | Create machine join-code and redeem on the disposable host; attempt expired/reused code. | Exactly one intended host enrolls; invalid or consumed codes do not enroll another. | +| Pair and enroll | Run `bb machine create --provider manual`, redeem its enrollment command on the disposable host, then retry the expired or consumed command. | Exactly one intended host enrolls; expired or consumed commands do not enroll another. | | Permission ceiling | Change the disposable machine ceiling and request a more permissive thread. | Host ceiling is enforced across UI, CLI, and runtime rather than merely hidden in the picker. | | Disconnect and reconnect | Stop only the disposable daemon, observe unavailable host, restart it, and retry a targeted read. | Status and routing recover to the same host; offline operations do not route to a different machine. | | Suspend and resume | Suspend a disposable provider-managed machine, send a thread follow-up to wake it, then repeat with the prompt banner's Resume action. | Every wake exposes a durable `resuming` lifecycle phase; the prompt banner says “Machine is resuming…” until the machine becomes active, and queued work dispatches once. | diff --git a/apps/cli/src/commands/machine.ts b/apps/cli/src/commands/machine.ts index 7d7e258ec9..2fd07db8bd 100644 --- a/apps/cli/src/commands/machine.ts +++ b/apps/cli/src/commands/machine.ts @@ -372,18 +372,6 @@ export function registerMachineCommands( }), ); - machine - .command("join-code") - .description("Create a short-lived machine pairing code") - .option("--json", "Print machine-readable JSON output") - .action( - action(async (opts: MachineListCommandOptions) => { - const result = await createCliBbSdk(getUrl()).hosts.createJoinCode(); - if (outputJson(opts, result)) return; - console.log(result.joinCode); - }), - ); - machine .command("rename ") .description("Rename an execution machine") diff --git a/apps/server/src/assets/install-machine.sh b/apps/server/src/assets/install-machine.sh index 12af00c650..7168f51325 100755 --- a/apps/server/src/assets/install-machine.sh +++ b/apps/server/src/assets/install-machine.sh @@ -4,21 +4,21 @@ set -eu usage() { cat >&2 <<'EOF' -Usage: install.sh --join-code --host-id --server [--machine-code ] [--host-daemon-port ] - install.sh --bootstrap-env +Usage: install.sh --bootstrap-env [--host-daemon-port ] install.sh --start|--stop|--uninstall --host-id [--server-url ] [--data-dir ] -The first three options are required. --machine-code is required through bb connect. +Machines enroll from a private bootstrap bundle. Get the one-line command that +carries it from Settings -> Machines -> Add a machine, or from +`bb machine create --provider manual`. That command works through bb connect, +Tailscale, and any other address machines can reach. By default, the installer assigns this enrolled daemon its own local API port. EOF exit 2 } bootstrap_env= -join_code= host_id= server_url= -machine_code= requested_host_daemon_port= lifecycle_action= requested_data_dir= @@ -26,7 +26,6 @@ requested_data_dir= CURL_CONNECT_TIMEOUT_SECONDS=10 PACKAGE_DOWNLOAD_TIMEOUT_SECONDS=300 PACKAGE_DOWNLOAD_RETRIES=3 -MACHINE_CODE_REDEEM_TIMEOUT_SECONDS=30 DAEMON_WAIT_ATTEMPTS=60 WAIT_PROGRESS_EVERY_ATTEMPTS=5 @@ -304,16 +303,13 @@ run_lifecycle() { while [ "$#" -gt 0 ]; do case "$1" in - --bootstrap-env|--join-code|--host-id|--server|--server-url|--machine-code|--host-daemon-port|--data-dir) + --bootstrap-env|--host-id|--server-url|--host-daemon-port|--data-dir) [ "$#" -ge 2 ] || usage [ -n "$2" ] || usage case "$1" in --bootstrap-env) bootstrap_env=$2 ;; - --join-code) join_code=$2 ;; --host-id) host_id=$2 ;; - --server) server_url=$2 ;; --server-url) server_url=$2 ;; - --machine-code) machine_code=$2 ;; --host-daemon-port) requested_host_daemon_port=$2 ;; --data-dir) requested_data_dir=$2 ;; esac @@ -333,9 +329,10 @@ while [ "$#" -gt 0 ]; do done if [ -n "$lifecycle_action" ]; then - [ -z "$bootstrap_env$join_code$machine_code$requested_host_daemon_port" ] || usage -elif [ -n "$bootstrap_env" ]; then - if [ -n "$join_code$host_id$server_url$machine_code" ]; then usage; fi + [ -z "$bootstrap_env$requested_host_daemon_port" ] || usage +else + [ -n "$bootstrap_env" ] || usage + if [ -n "$host_id$server_url" ]; then usage; fi host_id=$(node -e ' const name = process.argv[1]; if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) process.exit(2); @@ -355,8 +352,6 @@ elif [ -n "$bootstrap_env" ]; then ' "$bootstrap_env") || usage bootstrap_payload=$(node -e 'process.stdout.write(process.env[process.argv[1]])' "$bootstrap_env") unset "$bootstrap_env" -else - [ -n "$join_code" ] || usage fi [ -n "$host_id" ] || usage if [ -z "$lifecycle_action" ]; then [ -n "$server_url" ] || usage; fi @@ -733,72 +728,12 @@ fi bb_cli="${bb_app%/*}/bb" if [ ! -x "$bb_cli" ]; then bb_cli=$(command -v bb || true); fi -if [ -n "$bootstrap_env" ]; then - if [ -z "$bb_cli" ]; then - fail_step "The installed build does not provide the machine enrollment CLI." - exit 1 - fi - BB_ENROLLMENT="$bootstrap_payload" BB_DATA_DIR="$data_dir" "$bb_cli" machine enroll --bootstrap-env BB_ENROLLMENT - bootstrap_payload= -fi - -if [ -n "$machine_code" ]; then - connect_apex=$(node -e ' - const url = new URL(process.argv[1]); - const labels = url.hostname.split("."); - if (labels.length < 3) process.exit(2); - url.hostname = labels.slice(1).join("."); - url.pathname = "/"; - url.search = ""; - url.hash = ""; - process.stdout.write(url.origin); - ' "$server_url" 2>/dev/null) || { - fail_step "Could not derive the bb connect apex from $server_url." - exit 1 - } - active_step "Authorizing this machine with bb connect" - redeem_response=$(curl -fsS \ - --connect-timeout "$CURL_CONNECT_TIMEOUT_SECONDS" \ - --max-time "$MACHINE_CODE_REDEEM_TIMEOUT_SECONDS" \ - -X POST \ - -H 'content-type: application/json' \ - --data "{\"code\":\"$machine_code\"}" \ - "$connect_apex/api/connect/redeem-machine") || { - fail_step "Could not redeem the bb connect machine code." - exit 1 - } - printf '%s' "$redeem_response" | node -e ' - let input = ""; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk) => { input += chunk; }); - process.stdin.on("end", () => { - const body = JSON.parse(input); - if (typeof body.credential !== "string" || !body.credential.startsWith("bbcm_")) { - process.exit(2); - } - if (typeof body.machineId !== "string" || body.machineId.length === 0) { - process.exit(2); - } - const fs = require("node:fs"); - const path = require("node:path"); - const [dataDir, serverUrl] = process.argv.slice(1); - const configPath = path.join(dataDir, "config.json"); - let config = {}; - try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); } - catch (error) { if (error.code !== "ENOENT") throw error; } - config.serverUrl = serverUrl; - config.machineCredential = body.credential; - config.connectMachineId = body.machineId; - const temporary = `${configPath}.${process.pid}.tmp`; - fs.writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); - fs.renameSync(temporary, configPath); - }); - ' "$data_dir" "$server_url" || { - fail_step "The bb connect machine-code response was invalid." - exit 1 - } - complete_step "Authorized this machine with bb connect" +if [ -z "$bb_cli" ]; then + fail_step "The installed build does not provide the machine enrollment CLI." + exit 1 fi +BB_ENROLLMENT="$bootstrap_payload" BB_DATA_DIR="$data_dir" "$bb_cli" machine enroll --bootstrap-env BB_ENROLLMENT +bootstrap_payload= auth_matches_host() { node -e ' @@ -837,7 +772,6 @@ if [ "$already_joined" = no ]; then BB_APP_NPM_PREFIX="$bb_app_npm_prefix" BB_DATA_DIR="$data_dir" nohup "$bb_app" host-daemon join \ --auto-update \ --host-daemon-port "$host_daemon_port" \ - --join-code "$join_code" \ --host-id "$host_id" \ --server-url "$server_url" >"$join_log" 2>&1 & join_pid=$! @@ -878,7 +812,7 @@ if [ "$platform" = linux ] && [ "$(id -u)" = 0 ] && ! systemd-detect-virt --container --quiet >/dev/null 2>&1; then systemd_scope=--system fi -if [ -n "$bootstrap_env" ] && [ "$platform" = linux ] && +if [ "$platform" = linux ] && [ "$systemd_scope" = --user ] && ! systemctl --user show-environment >/dev/null 2>&1; then BB_INSTALL_SKIP_SERVICE=1 fi diff --git a/apps/server/test/app/install-machine-script.test.ts b/apps/server/test/app/install-machine-script.test.ts index 9994d2c5b5..e5280c37ad 100644 --- a/apps/server/test/app/install-machine-script.test.ts +++ b/apps/server/test/app/install-machine-script.test.ts @@ -54,6 +54,7 @@ function createScriptEnv( return { ...process.env, BB_DATA_DIR: fixture.dataDir, + BB_ENROLLMENT: bootstrapBundle(), HOME: fixture.homeDir, PATH: [fixture.binDir, "/usr/bin", "/bin"].join(delimiter), ...env, @@ -71,14 +72,16 @@ function runScript( }); } -const JOIN_ARGS = [ - "--join-code", - "join-secret", - "--host-id", - "host-test", - "--server", - "https://machine.getbb.app", -]; +const BOOTSTRAP_ARGS = ["--bootstrap-env", "BB_ENROLLMENT"]; + +function bootstrapBundle(serverUrl = "https://machine.getbb.app"): string { + return JSON.stringify({ + hostId: "host-test", + serverUrl, + credential: "join-secret", + expiresAt: 4102444800000, + }); +} function writeJoinedState( fixture: ReturnType, @@ -113,9 +116,36 @@ const option = (name) => { const index = cliArgs.indexOf(name); return index === -1 ? undefined : cliArgs[index + 1]; }; -${recordInvocation} const dataDir = process.env.BB_DATA_DIR; const hostId = ${JSON.stringify(args.hostId)}; +if (cliArgs[0] === "machine" && cliArgs[1] === "enroll") { + const bundle = JSON.parse(process.env[option("--bootstrap-env")]); + const enrolledUrl = new URL(bundle.serverUrl); + if (enrolledUrl.hostname === "localhost") enrolledUrl.hostname = "127.0.0.1"; + const authPath = path.join(dataDir, "auth.json"); + if (fs.existsSync(authPath)) { + if (JSON.parse(fs.readFileSync(authPath, "utf8")).hostId !== bundle.hostId) { + process.stderr.write("Refusing to overwrite a different machine identity\\n"); + process.exit(1); + } + process.exit(0); + } + fs.writeFileSync( + path.join(dataDir, "enrollment-argv"), + JSON.stringify(cliArgs) + "\\n", + ); + fs.writeFileSync( + path.join(dataDir, "auth.json"), + JSON.stringify({ hostId: bundle.hostId, hostKey: "secret" }) + "\\n", + ); + fs.writeFileSync( + path.join(dataDir, "config.json"), + JSON.stringify({ serverUrl: enrolledUrl.href.replace(/\\/$/u, "") }) + + "\\n", + ); + process.exit(0); +} +${recordInvocation} const port = Number(option("--host-daemon-port")); const serverUrl = option("--server-url"); const statusServerUrl = ${JSON.stringify(args.statusServerUrl)} ?? serverUrl; @@ -154,7 +184,6 @@ function writeServerInstallTools( `#!/bin/sh printf '%s\n' "$*" >>"${curlLog}" case "$*" in - *redeem-machine*) printf '%s' '{"credential":"bbcm_durable","machineId":"machine-1"}' ;; *) output= headers= @@ -230,6 +259,17 @@ function writeEnrollingBbApp( join(fixture.binDir, "bb-app"), createEnrollingBbAppScript({ hostId, invocationPath, statusServerUrl }), ); + writeEnrollingBb(fixture, hostId); +} + +function writeEnrollingBb( + fixture: ReturnType, + hostId = "host-test", +): void { + writeExecutable( + join(fixture.binDir, "bb"), + createEnrollingBbAppScript({ hostId }), + ); } function writeCurlArtifactMock( @@ -286,7 +326,7 @@ exec '${process.execPath}' "$@" ); writeExecutable(join(fixture.binDir, "id"), `#!/bin/sh\necho ${uid}\n`); writeCurlArtifactMock(fixture, 404); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { HOME: unset ? undefined : fixture.homeDir, }); expect(result.status).not.toBe(0); @@ -299,11 +339,11 @@ exec '${process.execPath}' "$@" it("rejects missing required flags with usage", () => { const fixture = createFixture(); - const result = runScript(["--join-code", "code-only"], fixture); + const result = runScript(["--host-id", "host-only"], fixture); expect(result.status).toBe(2); expect(result.stderr).toContain( - "Usage: install.sh --join-code --host-id --server ", + "Usage: install.sh --bootstrap-env [--host-daemon-port ]", ); }); @@ -497,7 +537,7 @@ exec '${process.execPath}' "$@" it("rejects an invalid explicit host-daemon port", () => { const fixture = createFixture(); const result = runScript( - [...JOIN_ARGS, "--host-daemon-port", "0"], + [...BOOTSTRAP_ARGS, "--host-daemon-port", "0"], fixture, ); @@ -507,32 +547,23 @@ exec '${process.execPath}' "$@" ); }); - it("renders an invalid server URL as an installer failure", () => { + it("rejects a bootstrap bundle carrying an unusable server URL", () => { const fixture = createFixture(); - const result = runScript( - [ - "--join-code", - "join-secret", - "--host-id", - "host-test", - "--server", - "not-a-url", - ], - fixture, - ); + const result = runScript(BOOTSTRAP_ARGS, fixture, { + BB_ENROLLMENT: bootstrapBundle("not-a-url"), + }); - expect(result.status).toBe(1); - expect(result.stderr).toContain( - " ✗ Could not parse the server URL not-a-url.", - ); + expect(result.status).toBe(2); + expect(result.stderr).toContain("Usage: install.sh --bootstrap-env "); expect(result.stderr).not.toContain("TypeError"); + expect(existsSync(join(fixture.dataDir, "auth.json"))).toBe(false); }); it("prefers the newly installed CLI and honors an explicit machine directory", () => { const fixture = createFixture(); writeServerInstallTools(fixture, 200); writeExecutable(join(fixture.binDir, "npm"), "#!/bin/sh\nexit 19\n"); - runScript(JOIN_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1" }); + runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1" }); const olderCli = join( fixture.homeDir, ".bb-machines", @@ -569,7 +600,7 @@ exec '${process.execPath}' "$@" const fixture = createFixture(); writeServerInstallTools(fixture, 200); writeExecutable(join(fixture.binDir, "npm"), "#!/bin/sh\nexit 19\n"); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); expect(result.status).toBe(1); @@ -669,33 +700,31 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi }, ); - it("uses bb-app from PATH and passes the launcher join flags verbatim", () => { + it("uses bb-app from PATH and enrolls from the private bootstrap bundle", () => { const fixture = createFixture(); const invocationPath = join(fixture.dataDir, "invocation"); writeCurlArtifactMock(fixture, 404); writeEnrollingBbApp(fixture, invocationPath); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); - expect( - result.status, - `${result.stderr}\n${readFileSync(join(fixture.dataDir, "install-join.log"), "utf8")}`, - ).toBe(0); + expect(result.status, result.stderr).toBe(0); const selectedPort = readFileSync( join(fixture.dataDir, "host-daemon-port"), "utf8", ).trim(); + expect( + JSON.parse( + readFileSync(join(fixture.dataDir, "enrollment-argv"), "utf8"), + ), + ).toEqual(["machine", "enroll", "--bootstrap-env", "BB_ENROLLMENT"]); + expect(result.stdout + result.stderr).not.toContain("join-secret"); expect(readFileSync(invocationPath, "utf8").trim().split("\n")).toEqual([ "host-daemon", - "join", "--auto-update", "--host-daemon-port", selectedPort, - "--join-code", - "join-secret", - "--host-id", - "host-test", "--server-url", "https://machine.getbb.app", ]); @@ -716,7 +745,7 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi writeEnrollingBbApp(fixture, invocationPath); writeJoinedState(fixture); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); @@ -749,20 +778,23 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi "host-test", "http://127.0.0.1:20101", ); - const result = runScript( - [ - "--join-code", - "join-secret", - "--host-id", - "host-test", - "--server", - "http://localhost:20101", - ], - fixture, - { BB_INSTALL_SKIP_SERVICE: "1" }, - ); + const result = runScript(BOOTSTRAP_ARGS, fixture, { + BB_ENROLLMENT: bootstrapBundle("http://localhost:20101"), + BB_INSTALL_SKIP_SERVICE: "1", + }); expect(result.status, result.stderr).toBe(0); + expect(readFileSync(invocationPath, "utf8").trim().split("\n")).toEqual([ + "host-daemon", + "join", + "--auto-update", + "--host-daemon-port", + readFileSync(join(fixture.dataDir, "host-daemon-port"), "utf8").trim(), + "--host-id", + "host-test", + "--server-url", + "http://localhost:20101", + ]); const daemonPid = Number( readFileSync(join(fixture.dataDir, "install-daemon.pid"), "utf8"), ); @@ -773,7 +805,7 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi const fixture = createFixture(); writeServerInstallTools(fixture, 200); writeExecutable(join(fixture.binDir, "bb-app"), "#!/bin/sh\nexit 99\n"); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); @@ -794,7 +826,7 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi it("prefers the server-matched tarball when bb-app is absent", () => { const fixture = createFixture(); writeServerInstallTools(fixture, 200); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); @@ -827,10 +859,8 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi " ○ Installing the server's bb-app build", ); expect(result.stdout).toContain(" ✓ Installed the server's bb-app build"); - expect(result.stdout).toContain( - "Waiting for the temporary host daemon to connect", - ); - expect(result.stdout).toContain("Join progress is logged to"); + expect(result.stdout).toContain("Waiting for the host daemon to connect"); + expect(result.stdout).toContain("Host daemon output is logged to"); const daemonPid = Number( readFileSync(join(fixture.dataDir, "install-daemon.pid"), "utf8"), ); @@ -841,7 +871,7 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi const fixture = createFixture(); writeServerInstallTools(fixture, 200, FIXTURE_ARTIFACT_DIGEST, 1); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); @@ -861,12 +891,12 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi it("skips downloading and installing an identical host artifact", () => { const fixture = createFixture(); writeServerInstallTools(fixture, 200); - const first = runScript(JOIN_ARGS, fixture, { + const first = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); expect(first.status, first.stderr).toBe(0); - const second = runScript(JOIN_ARGS, fixture, { + const second = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); @@ -893,7 +923,7 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi const fixture = createFixture(); writeServerInstallTools(fixture, 200, "a".repeat(64)); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); @@ -908,7 +938,7 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi it("falls back to npm only when the server artifact returns 404", () => { const fixture = createFixture(); writeServerInstallTools(fixture, 404); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); @@ -925,7 +955,7 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi it("fails loudly when npm skipped the native add-on install scripts", () => { const fixture = createFixture(); writeServerInstallTools(fixture, 200); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", FAKE_NPM_SKIP_NATIVE_MODULES: "1", }); @@ -943,7 +973,7 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi it("defaults the data dir to a per-server directory under ~/.bb-machines", () => { const fixture = createFixture(); writeServerInstallTools(fixture, 200); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_DATA_DIR: "", BB_INSTALL_SKIP_SERVICE: "1", }); @@ -966,14 +996,18 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi const fixture = createFixture(); writeCurlArtifactMock(fixture, 404); writeExecutable(join(fixture.binDir, "bb-app"), "#!/bin/sh\nexit 99\n"); + writeEnrollingBb(fixture); writeJoinedState(fixture, "https://machine.getbb.app", "host-other"); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); expect(result.status).toBe(1); - expect(result.stderr).toContain("credentials for a different host"); - expect(result.stdout).not.toContain("Joined successfully"); + expect(result.stderr).toContain( + "Refusing to overwrite a different machine identity", + ); + expect(result.stdout).not.toContain("Host daemon connected"); + expect(existsSync(join(fixture.dataDir, "install-daemon.pid"))).toBe(false); }); it("assigns a different port when the first enrolled-daemon port is occupied", async () => { @@ -1002,7 +1036,7 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi writeEnrollingBbApp(fixture, invocationPath); try { - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); @@ -1028,47 +1062,6 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi } }); - it("redeems and persists a connect machine code before joining through the tunnel", () => { - const fixture = createFixture(); - const invocationPath = join(fixture.dataDir, "invocation"); - writeServerInstallTools(fixture, 404); - writeEnrollingBbApp(fixture, invocationPath); - const result = runScript( - [ - "--join-code", - "join-secret", - "--host-id", - "host-test", - "--server", - "https://sawyer.getbb.app", - "--machine-code", - "MACH-INE1", - ], - fixture, - { BB_INSTALL_SKIP_SERVICE: "1" }, - ); - - expect(result.status, result.stderr).toBe(0); - expect(readFileSync(join(fixture.dataDir, "curl.log"), "utf8")).toContain( - "--connect-timeout 10 --max-time 30 -X POST", - ); - expect(readFileSync(invocationPath, "utf8")).not.toContain("bbcm_durable"); - expect(readFileSync(invocationPath, "utf8")).not.toContain( - "--machine-credential", - ); - expect( - JSON.parse(readFileSync(join(fixture.dataDir, "config.json"), "utf8")), - ).toMatchObject({ - connectMachineId: "machine-1", - machineCredential: "bbcm_durable", - serverUrl: "https://sawyer.getbb.app", - }); - const daemonPid = Number( - readFileSync(join(fixture.dataDir, "install-daemon.pid"), "utf8"), - ); - process.kill(daemonPid, "SIGTERM"); - }); - it("reports periodic progress while a host daemon is still joining", () => { const fixture = createFixture(); writeCurlArtifactMock(fixture, 404); @@ -1078,21 +1071,22 @@ fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringi setInterval(() => {}, 1000); `, ); + writeEnrollingBb(fixture); writeExecutable(join(fixture.binDir, "sleep"), "#!/bin/sh\nexit 0\n"); - const result = runScript(JOIN_ARGS, fixture, { + const result = runScript(BOOTSTRAP_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); expect(result.status).toBe(1); expect(result.stdout).toContain( - "Still waiting for the temporary host daemon (5/60 checks)", + "Still waiting for the host daemon (5/60 checks)", ); expect(result.stdout).toContain( - "Still waiting for the temporary host daemon (60/60 checks)", + "Still waiting for the host daemon (60/60 checks)", ); - expect(result.stderr).toContain("Timed out waiting for host daemon"); - }); + expect(result.stderr).toContain("The bb host daemon did not connect"); + }, 15_000); it("starts a fresh macOS launch agent once and replaces it with one new process", () => { const fixture = createFixture(); @@ -1126,18 +1120,8 @@ fi `, ); - const firstResult = runScript([...JOIN_ARGS], fixture); - const secondResult = runScript( - [ - "--join-code", - "unused-reinstall-code", - "--host-id", - "host-test", - "--server", - "https://machine.getbb.app", - ], - fixture, - ); + const firstResult = runScript([...BOOTSTRAP_ARGS], fixture); + const secondResult = runScript(BOOTSTRAP_ARGS, fixture); expect(firstResult.status, firstResult.stderr).toBe(0); expect(firstResult.stdout).toContain("already joined"); @@ -1228,7 +1212,7 @@ fi `, ); - const result = runScript(JOIN_ARGS, fixture); + const result = runScript(BOOTSTRAP_ARGS, fixture); expect(result.status, result.stderr).toBe(0); expect(existsSync(legacyServiceFile)).toBe(false); @@ -1261,7 +1245,7 @@ fi `, ); - const result = runScript(JOIN_ARGS, fixture); + const result = runScript(BOOTSTRAP_ARGS, fixture); expect(result.status).toBe(1); expect(result.stderr).toContain( @@ -1283,7 +1267,7 @@ printf '%s\n' "$*" >>"${join(fixture.dataDir, "launchctl.log")}" ); writeExecutable(join(fixture.binDir, "sleep"), "#!/bin/sh\nexit 0\n"); - const result = runScript(JOIN_ARGS, fixture); + const result = runScript(BOOTSTRAP_ARGS, fixture); expect(result.status).toBe(1); expect(result.stderr).toContain( @@ -1314,17 +1298,7 @@ fi `, ); - const result = runScript( - [ - "--join-code", - "unused-fresh-code", - "--host-id", - "host-test", - "--server", - "https://machine.getbb.app", - ], - fixture, - ); + const result = runScript(BOOTSTRAP_ARGS, fixture); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain("already joined"); @@ -1349,7 +1323,7 @@ fi `Environment="BB_APP_NPM_PREFIX=${realpathSync(fixture.dataDir)}/npm"`, ); expect(readFileSync(join(fixture.dataDir, "systemctl.log"), "utf8")).toBe( - "--user daemon-reload\n--user enable bb-host-daemon-machine-getbb-app-host-test.service\n--user restart bb-host-daemon-machine-getbb-app-host-test.service\n", + "--user show-environment\n--user daemon-reload\n--user enable bb-host-daemon-machine-getbb-app-host-test.service\n--user restart bb-host-daemon-machine-getbb-app-host-test.service\n", ); }); @@ -1379,17 +1353,7 @@ fi `, ); - const result = runScript( - [ - "--join-code", - "unused-fresh-code", - "--host-id", - "host-test", - "--server", - "https://machine.getbb.app", - ], - fixture, - ); + const result = runScript(BOOTSTRAP_ARGS, fixture); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain("already joined"); @@ -1428,7 +1392,7 @@ fi "systemd/bb-host-daemon-machine-getbb-app-host-test.service", ); expect(readFileSync(join(fixture.dataDir, "systemctl.log"), "utf8")).toBe( - `${scope} daemon-reload\n${scope} enable ${enableUnit}\n${scope} restart bb-host-daemon-machine-getbb-app-host-test.service\n`, + `${container ? "--user show-environment\n" : ""}${scope} daemon-reload\n${scope} enable ${enableUnit}\n${scope} restart bb-host-daemon-machine-getbb-app-host-test.service\n`, ); }, ); @@ -1463,7 +1427,7 @@ fi `, ); - const result = runScript(JOIN_ARGS, fixture); + const result = runScript(BOOTSTRAP_ARGS, fixture); expect(result.status, result.stderr).toBe(0); expect(existsSync(legacyServiceFile)).toBe(false); @@ -1471,7 +1435,7 @@ fi readdirSync(serviceDir).filter((file) => file.endsWith(".service")), ).toEqual(["bb-host-daemon-machine-getbb-app-host-test.service"]); expect(readFileSync(join(fixture.dataDir, "systemctl.log"), "utf8")).toBe( - "--user disable --now bb-host-daemon-machine-getbb-app.service\n--user daemon-reload\n--user enable bb-host-daemon-machine-getbb-app-host-test.service\n--user restart bb-host-daemon-machine-getbb-app-host-test.service\n", + "--user show-environment\n--user disable --now bb-host-daemon-machine-getbb-app.service\n--user daemon-reload\n--user enable bb-host-daemon-machine-getbb-app-host-test.service\n--user restart bb-host-daemon-machine-getbb-app-host-test.service\n", ); }); }); diff --git a/packages/sdk/src/areas/hosts.ts b/packages/sdk/src/areas/hosts.ts index 8dd238af3d..561abdcaf3 100644 --- a/packages/sdk/src/areas/hosts.ts +++ b/packages/sdk/src/areas/hosts.ts @@ -103,6 +103,7 @@ export interface HostsArea { experimental_getEnrollmentCommand( args: HostGetArgs, ): Promise; + /** @deprecated Use experimental_create() and experimental_getEnrollmentCommand() for bootstrap enrollment. */ createJoinCode(): Promise; delete(args: HostDeleteArgs): Promise; experimental_deleteOldServerCopy( diff --git a/packages/templates/src/templates/bb-guide-machines.md b/packages/templates/src/templates/bb-guide-machines.md index c31b0ab415..f6713ea836 100644 --- a/packages/templates/src/templates/bb-guide-machines.md +++ b/packages/templates/src/templates/bb-guide-machines.md @@ -63,7 +63,6 @@ bb machine create --provider Create a standalone machine bb machine enroll --bootstrap-file --bootstrap-env Alternative private bundle source bb machine show Show machine details -bb machine join-code Create a machine pairing code bb machine rename Rename a machine bb machine retry-update Retry a pending daemon update now bb machine reconcile Reconcile compute with core’s recorded state diff --git a/plugins/bb-guide/skills/bb-cli/references/command-index.md b/plugins/bb-guide/skills/bb-cli/references/command-index.md index 0b4f015f33..cc09daedec 100644 --- a/plugins/bb-guide/skills/bb-cli/references/command-index.md +++ b/plugins/bb-guide/skills/bb-cli/references/command-index.md @@ -79,7 +79,6 @@ This index lists every command path that the core CLI registers, including alias - `bb machine create` - `bb machine list` - `bb machine show` -- `bb machine join-code` - `bb machine rename` - `bb machine remove` - `bb machine suspend` @@ -341,3 +340,7 @@ Machine environment: `bb machine env list`, `bb machine env set NAME` (value from stdin), and `bb machine env unset NAME`; all accept `--project ` for project overrides and `--json`. Omit `--project` for global settings. Standalone `bb machine create` machines remain until explicitly removed. + +To enroll an existing machine, run `bb machine create --provider manual`, then +run its printed enrollment command on the target. The CLI waits until the daemon +connects. With `--no-wait`, it returns the creating host ID immediately. diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md index bbe137475b..ff50adb8f9 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md @@ -96,7 +96,7 @@ worktree` only; a provider takes its branch through `--environment-inputs`. surface that sets it, and machine credentials are refused — so read it from `bb machine list --json` or `bb machine show` and ask the user to change it in the app. -- `bb machine providers`, `show`, `join-code`, `rename`, `retry-update`, +- `bb machine providers`, `show`, `rename`, `retry-update`, `suspend`, `resume`, `retry-cleanup`, and `remove` cover the Settings → Machines lifecycle. Use `bb machine provider-cli status|install` to inspect or install provider CLIs on a selected machine. @@ -273,6 +273,10 @@ directory only. ### Private machine enrollment +For an existing machine, use `bb machine create --provider manual` and run the +printed command on the target. That command installs bb if needed and enrolls +the machine with this server. + Use `bb machine enroll --bootstrap-file ` or `--bootstrap-env ` on a machine that already has the CLI. Core prepares the versioned bundle; transport it through a private file or environment/stdin, never command arguments, logs, resource JSON, or a transcript. Enrollment refuses a different existing host/server identity and succeeds without another exchange when the same identity is already enrolled. The installer accepts `--bootstrap-env ` and invokes this command after installing bb. Machine state defaults to `~/.bb-machines/`; an explicit `BB_DATA_DIR` must be isolated from the default BB instance. For remote non-login commands, discover `bb` on PATH and fall back to `~/.local/bin/bb`. Delivered enrollment bundles from v1 remain valid until their expiry. The CLI accepts both file and environment forms, upgrades the bundle to v2 headers locally, and persists legacy Connect redemption before enrollment so a retry reuses it. The installer upgrades v1 environment bundles before authenticated artifact downloads. diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-sdk.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-sdk.md index a2f9ef8c3c..aefd5974b0 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-sdk.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-sdk.md @@ -23,7 +23,7 @@ signatures (see "Looking up the exact API"). | `threadSections` | `list` `create` `update` `delete` | | `projects` | `list` `get` `create` `update` `delete` `reorder` `paths` `files` `fileContent` `branches` `commands` `defaultExecutionOptions` `promptHistory` `sidebarBootstrap`; sub-areas `attachments` (`upload` `read` `copy`), `sources` (`add` `update` `delete`) | | `environments` | `list` `listProviders` `get` `update` `delete` `status` `paths` `commit` `archiveThreads` `diff` `diffFile` `diffFiles` `diffBranches` `diffPatch` `pullRequest` `markPullRequestDraft` `markPullRequestReady` `mergePullRequest` | -| `hosts` | `create` `list` `listProviders` `get` `update` `delete` `directory` `pathsExist` `pickFolder` `cloneDefaultPath` `createJoinCode` `suspend` `resume` `retryCleanup` `retryUpdate` `providerCliStatus` `installProviderCli` | +| `hosts` | `create` `list` `listProviders` `get` `update` `delete` `directory` `pathsExist` `pickFolder` `cloneDefaultPath` `createJoinCode` (deprecated; use `experimental_create` and `experimental_getEnrollmentCommand`) `suspend` `resume` `retryCleanup` `retryUpdate` `providerCliStatus` `installProviderCli` | | `files` | `read` `write` `list` `listPaths` `mkdir` `move` `remove` `createPreview` | | `terminals` | `list` `create` `get` `input` `output` `resize` `rename` `restart` `close` | | `providers` | `list` `models` | From 7d6b21b7e4dd66fefa2ecb66eb0935e136051e37 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 21 Sep 2026 09:52:05 -0700 Subject: [PATCH 2/3] Preserve join-code migration guidance --- apps/cli/src/commands/machine.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/cli/src/commands/machine.ts b/apps/cli/src/commands/machine.ts index 2fd07db8bd..8e81c7ab66 100644 --- a/apps/cli/src/commands/machine.ts +++ b/apps/cli/src/commands/machine.ts @@ -218,6 +218,19 @@ export function registerMachineCommands( }), ); + machine + .command("join-code", { hidden: true }) + .description("Compatibility notice for removed machine join codes") + .option("--json", "Print machine-readable JSON output") + .action( + action(async () => { + throw new CliExitError("bb machine join-code has been removed.", 1, { + code: "removed_command", + hint: "Use `bb machine create --provider manual` and run the printed enrollment command.", + }); + }), + ); + machine .command("create") .description("Create a machine using an installed provider") From 5771c92b66e285223e7c0275f14ac9deb81eaec4 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 21 Sep 2026 10:01:50 -0700 Subject: [PATCH 3/3] Fix hidden command and SDK CI guards --- .../src/__tests__/bb-cli-skill-coverage.test.ts | 17 ++++++++++------- packages/domain/src/plugin-sdk-version.ts | 2 +- packages/plugin-sdk/package.json | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/__tests__/bb-cli-skill-coverage.test.ts b/apps/cli/src/__tests__/bb-cli-skill-coverage.test.ts index 6fb987f2cc..4843f5c3a4 100644 --- a/apps/cli/src/__tests__/bb-cli-skill-coverage.test.ts +++ b/apps/cli/src/__tests__/bb-cli-skill-coverage.test.ts @@ -23,13 +23,16 @@ const BB_CLI_SKILL_ROOT = fileURLToPath( ); function commandPaths(command: Command, prefix: string[] = []): string[] { - return command.commands.flatMap((child) => { - const path = [...prefix, child.name()]; - const aliases = child - .aliases() - .map((alias) => [...prefix, alias].join(" ")); - return [path.join(" "), ...aliases, ...commandPaths(child, path)]; - }); + const visible = new Set(command.createHelp().visibleCommands(command)); + return command.commands + .filter((child) => visible.has(child)) + .flatMap((child) => { + const path = [...prefix, child.name()]; + const aliases = child + .aliases() + .map((alias) => [...prefix, alias].join(" ")); + return [path.join(" "), ...aliases, ...commandPaths(child, path)]; + }); } function readMarkdownTree(directory: string): string { diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 3d1be9edfb..c7b26a57a1 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.4.108"; +export const PLUGIN_SDK_VERSION = "0.4.109"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 4899b3afe6..bbd945f3bf 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.108", + "version": "0.4.109", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues"