diff --git a/.bb/skills/verify-bb/INVENTORY.md b/.bb/skills/verify-bb/INVENTORY.md index 6c96c125f5c..bd1f597cc0b 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 6193f7ecb20..a548b5d19f7 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/__tests__/bb-cli-skill-coverage.test.ts b/apps/cli/src/__tests__/bb-cli-skill-coverage.test.ts index 6fb987f2cce..4843f5c3a4f 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/apps/cli/src/commands/machine.ts b/apps/cli/src/commands/machine.ts index 7d7e258ec9d..8e81c7ab66e 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") @@ -372,18 +385,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 12af00c650c..7168f51325a 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 9994d2c5b59..e5280c37ad8 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/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 3d1be9edfb6..c7b26a57a17 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 4899b3afe66..bbd945f3bf0 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" diff --git a/packages/sdk/src/areas/hosts.ts b/packages/sdk/src/areas/hosts.ts index 8dd238af3d0..561abdcaf3a 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 c31b0ab4154..f6713ea8369 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 a5055647529..24132468da7 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` @@ -342,3 +341,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 bbe137475bd..ff50adb8f90 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 a2f9ef8c3ca..aefd5974b0f 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` |