diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 7bd2069e44..877a703821 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -38,6 +38,15 @@ jobs: has_signing_secrets: ${{ steps.signing.outputs.has_signing_secrets }} steps: + - name: Require main branch for stable publication + if: >- + ${{ inputs.publish == true + && inputs.release_channel == 'stable' + && github.ref != 'refs/heads/main' }} + run: | + echo "::error::Stable desktop publication must run from the main branch, got ${GITHUB_REF}." + exit 1 + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -70,6 +79,7 @@ jobs: - name: Validate macOS signing secrets id: signing + if: ${{ inputs.publish == true && inputs.release_channel == 'stable' }} env: MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} @@ -112,12 +122,12 @@ jobs: - name: Package arm64 desktop artifacts env: - CSC_LINK: ${{ secrets.MACOS_CERTIFICATE_P12 }} - CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - CSC_NAME: ${{ secrets.MACOS_CERTIFICATE_NAME }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + CSC_LINK: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.MACOS_CERTIFICATE_P12 || '' }} + CSC_KEY_PASSWORD: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.MACOS_CERTIFICATE_PASSWORD || '' }} + CSC_NAME: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.MACOS_CERTIFICATE_NAME || '' }} + APPLE_ID: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.APPLE_ID || '' }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.APPLE_APP_PASSWORD || '' }} + APPLE_TEAM_ID: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.APPLE_TEAM_ID || '' }} run: pnpm exec turbo run desktop:build --filter=@bb/desktop --force --output-logs=new-only - name: Smoke test packaged desktop app @@ -147,6 +157,15 @@ jobs: timeout-minutes: 45 steps: + - name: Require main branch for stable publication + if: >- + ${{ inputs.publish == true + && inputs.release_channel == 'stable' + && github.ref != 'refs/heads/main' }} + run: | + echo "::error::Stable desktop publication must run from the main branch, got ${GITHUB_REF}." + exit 1 + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -203,12 +222,24 @@ jobs: needs: - macos - linux + if: ${{ inputs.publish == true && inputs.release_channel == 'stable' }} runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 20 permissions: contents: write + id-token: write + attestations: write steps: + - name: Require main branch for stable publication + if: >- + ${{ inputs.publish == true + && inputs.release_channel == 'stable' + && github.ref != 'refs/heads/main' }} + run: | + echo "::error::Stable desktop publication must run from the main branch, got ${GITHUB_REF}." + exit 1 + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -264,6 +295,24 @@ jobs: name: bb-desktop-linux-x64 path: release/linux + - name: Attest Linux AppImage provenance + if: steps.release_plan.outputs.should_publish == 'true' + id: attest_linux + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + with: + subject-path: release/linux/*.AppImage + + - name: Attest macOS desktop provenance + if: >- + ${{ steps.release_plan.outputs.should_publish == 'true' + && steps.release_plan.outputs.publish_macos_binaries == 'true' }} + id: attest_macos + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + with: + subject-path: | + release/macos/*.dmg + release/macos/*.zip + - name: Publish stable desktop release feed if: steps.release_plan.outputs.should_publish == 'true' env: @@ -360,6 +409,8 @@ jobs: - name: Summarize env: + ATTESTATION_LINUX_URL: ${{ steps.attest_linux.outputs.attestation-url }} + ATTESTATION_MACOS_URL: ${{ steps.attest_macos.outputs.attestation-url }} IS_PRERELEASE: ${{ steps.release_plan.outputs.is_prerelease }} PUBLISH_MACOS_BINARIES: ${{ steps.release_plan.outputs.publish_macos_binaries }} SHOULD_PUBLISH: ${{ steps.release_plan.outputs.should_publish }} @@ -376,4 +427,6 @@ jobs: echo "- macOS release binary upload enabled: ${PUBLISH_MACOS_BINARIES}" echo "- macOS feed URL: https://github.com/get-bb/bb/releases/download/desktop-latest/desktop-version.json" echo "- Linux feed URL: https://github.com/get-bb/bb/releases/download/desktop-latest/desktop-version-linux.json" + echo "- Linux binary provenance attestation: ${ATTESTATION_LINUX_URL}" + echo "- macOS binary provenance attestation: ${ATTESTATION_MACOS_URL:-not published}" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/check-release-hardening.mjs b/.github/workflows/check-release-hardening.mjs new file mode 100644 index 0000000000..ef937d5c26 --- /dev/null +++ b/.github/workflows/check-release-hardening.mjs @@ -0,0 +1,209 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); + +function readRepoFile(relativePath) { + return readFileSync(resolve(repoRoot, relativePath), "utf8"); +} + +const failures = []; + +function assert(condition, message) { + if (!condition) { + failures.push(message); + } +} + +function jobSection(workflow, jobName) { + const start = workflow.indexOf(`\n ${jobName}:\n`); + if (start === -1) { + return ""; + } + + const rest = workflow.slice(start + 1); + const nextJob = rest.search(/\n [A-Za-z0-9_-]+:\n/u); + return nextJob === -1 ? rest : rest.slice(0, nextJob); +} + +function assertGateBeforeCheckout( + workflow, + jobName, + gateName, + message, + condition, +) { + const section = jobSection(workflow, jobName); + const gate = section.indexOf(`- name: ${gateName}`); + const checkout = section.indexOf("- name: Checkout repository"); + + assert(section.length > 0, `${message}: job is missing`); + assert( + gate !== -1 && gate < checkout, + `${message}: gate must precede checkout`, + ); + if (condition) { + assert(section.includes(condition), `${message}: missing ${condition}`); + } +} + +const buildDesktop = readRepoFile(".github/workflows/build-desktop.yml"); +for (const jobName of ["macos", "linux", "publish"]) { + assertGateBeforeCheckout( + buildDesktop, + jobName, + "Require main branch for stable publication", + `build-desktop/${jobName}`, + "inputs.publish == true", + ); +} +assert( + jobSection(buildDesktop, "publish").includes( + "if: ${{ inputs.publish == true && inputs.release_channel == 'stable' }}", + ), + "build-desktop/publish: QA runs must not receive publication permissions", +); +assert( + buildDesktop.includes( + "CSC_LINK: ${{ inputs.publish == true && inputs.release_channel == 'stable' && secrets.MACOS_CERTIFICATE_P12 || '' }}", + ), + "build-desktop: signing secrets must be withheld from QA packaging", +); +assert( + buildDesktop.includes( + "uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0", + ), + "build-desktop: stable binaries need a pinned provenance action", +); +assert( + jobSection(buildDesktop, "publish").includes("attestations: write"), + "build-desktop/publish: artifact-attestation permission is required", +); +const stableDesktopPublish = jobSection(buildDesktop, "publish"); +assert( + stableDesktopPublish.includes("id: attest_linux") && + stableDesktopPublish.includes("subject-path: release/linux/*.AppImage"), + "build-desktop/publish: Linux AppImage attestation must cover published assets", +); +assert( + stableDesktopPublish.includes("id: attest_macos") && + stableDesktopPublish.includes( + "steps.release_plan.outputs.publish_macos_binaries == 'true'", + ), + "build-desktop/publish: macOS attestation must be conditional on published assets", +); + +for (const workflowName of [ + "deploy-connect.yml", + "deploy-demo-server.yml", + "deploy-web.yml", +]) { + const workflow = readRepoFile(`.github/workflows/${workflowName}`); + assertGateBeforeCheckout( + workflow, + "deploy", + "Require main branch", + workflowName, + "github.ref != 'refs/heads/main'", + ); +} + +const mobileEas = readRepoFile(".github/workflows/mobile-ios-eas.yml"); +assertGateBeforeCheckout( + mobileEas, + "build", + "Require main branch for TestFlight submission", + "mobile-ios-eas/build", + "inputs.submit == true", +); + +const publish = readRepoFile(".github/workflows/publish-bb-app.yml"); +for (const jobName of ["publish", "publish-nightly", "publish-plugin-sdk"]) { + assertGateBeforeCheckout( + publish, + jobName, + "Require main branch", + `publish-bb-app/${jobName}`, + "github.ref != 'refs/heads/main'", + ); +} +for (const jobName of [ + "nightly-desktop-macos", + "nightly-desktop-linux", + "nightly-desktop-publish", +]) { + assertGateBeforeCheckout( + publish, + jobName, + jobName === "nightly-desktop-publish" + ? "Require main branch for manual nightly publication" + : "Require main branch for manual nightly release", + `publish-bb-app/${jobName}`, + "github.event_name == 'workflow_dispatch'", + ); +} +assert( + !publish.includes("npm@latest"), + "publish-bb-app: npm@latest is forbidden", +); +assert( + publish.match(/npm install --global npm@11\.6\.2/g)?.length === 3, + "publish-bb-app: all three npm jobs must install npm 11.6.2", +); +assert( + publish.includes( + "uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0", + ), + "publish-bb-app: nightly binaries need a pinned provenance action", +); +assert( + jobSection(publish, "nightly-desktop-publish").includes( + "attestations: write", + ), + "publish-bb-app/nightly-desktop-publish: artifact-attestation permission is required", +); + +for (const workflowName of ["mobile-e2e.yml", "mobile-runner-probe.yml"]) { + assert( + !readRepoFile(`.github/workflows/${workflowName}`).includes( + "Require main branch", + ), + `${workflowName}: QA workflow must remain branch-flexible`, + ); +} + +const rootPackage = JSON.parse(readRepoFile("package.json")); +const overrides = rootPackage.pnpm?.overrides ?? {}; +assert( + overrides["@ungap/structured-clone"] === "1.3.4", + "package.json: @ungap/structured-clone must be overridden to 1.3.4", +); +assert( + overrides["@xmldom/xmldom@0.8.13"] === "0.8.15", + "package.json: @xmldom/xmldom 0.8.x must be overridden to 0.8.15", +); +assert( + overrides["@xmldom/xmldom@0.9.10"] === "0.9.12", + "package.json: @xmldom/xmldom 0.9.x must be overridden to 0.9.12", +); + +const lockfile = readRepoFile("pnpm-lock.yaml"); +const resolvedPackages = lockfile.slice(lockfile.indexOf("\npackages:")); +for (const forbidden of [ + "@ungap/structured-clone@1.3.0", + "@xmldom/xmldom@0.8.13", + "@xmldom/xmldom@0.9.10", + "Potential CWE-502 - Update to 1.3.1 or higher", + "this version has critical issues, please update to the latest version", +]) { + const source = forbidden.includes("CWE") ? lockfile : resolvedPackages; + assert(!source.includes(forbidden), `pnpm-lock.yaml: stale ${forbidden}`); +} + +if (failures.length > 0) { + console.error(failures.map((failure) => `- ${failure}`).join("\n")); + process.exit(1); +} + +console.log("Release workflow and dependency hardening checks passed."); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f4e36a8c3..3173683e0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: pnpm-version: ${{ env.PNPM_VERSION }} cache-prefix: checks + - name: Check release workflow hardening + run: node .github/workflows/check-release-hardening.mjs + # Four tasks for four vCPUs: TypeScript 7 and esbuild are multi-threaded, # so turbo's default of ten concurrent tasks only adds contention here. # Measured pinned to 4 CPUs, two rounds each: 96 s / 110 s at the diff --git a/.github/workflows/deploy-connect.yml b/.github/workflows/deploy-connect.yml index 86dfd12ed1..ccfc5fba5e 100644 --- a/.github/workflows/deploy-connect.yml +++ b/.github/workflows/deploy-connect.yml @@ -33,6 +33,12 @@ jobs: timeout-minutes: 20 steps: + - name: Require main branch + if: ${{ github.ref != 'refs/heads/main' }} + run: | + echo "::error::Deploy Connect must run from the main branch, got ${GITHUB_REF}." + exit 1 + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/deploy-demo-server.yml b/.github/workflows/deploy-demo-server.yml index 7a73ce6c75..2e26fe6a7c 100644 --- a/.github/workflows/deploy-demo-server.yml +++ b/.github/workflows/deploy-demo-server.yml @@ -41,6 +41,12 @@ jobs: timeout-minutes: 15 steps: + - name: Require main branch + if: ${{ github.ref != 'refs/heads/main' }} + run: | + echo "::error::Deploy Demo Server must run from the main branch, got ${GITHUB_REF}." + exit 1 + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 7276849957..fb1447328a 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -32,6 +32,12 @@ jobs: timeout-minutes: 20 steps: + - name: Require main branch + if: ${{ github.ref != 'refs/heads/main' }} + run: | + echo "::error::Deploy Web must run from the main branch, got ${GITHUB_REF}." + exit 1 + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/mobile-ios-eas.yml b/.github/workflows/mobile-ios-eas.yml index 2c60533732..15c16bd740 100644 --- a/.github/workflows/mobile-ios-eas.yml +++ b/.github/workflows/mobile-ios-eas.yml @@ -69,6 +69,14 @@ jobs: cancel-in-progress: false steps: + - name: Require main branch for TestFlight submission + if: >- + ${{ inputs.submit == true + && github.ref != 'refs/heads/main' }} + run: | + echo "::error::TestFlight submission must run from the main branch, got ${GITHUB_REF}." + exit 1 + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/publish-bb-app.yml b/.github/workflows/publish-bb-app.yml index 71bcac1c14..c7df4a3078 100644 --- a/.github/workflows/publish-bb-app.yml +++ b/.github/workflows/publish-bb-app.yml @@ -88,18 +88,10 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org - - name: Ensure npm supports trusted publishing + - name: Install pinned npm for trusted publishing run: | - npm_version="$(npm --version)" - node - "$npm_version" <<'NODE' || npm install --global npm@latest - const version = process.argv[2]; - const [major, minor, patch] = version.split(".").map(Number); - const supported = - major > 11 || - (major === 11 && (minor > 5 || (minor === 5 && patch >= 1))); - process.exit(supported ? 0 : 1); - NODE - npm --version + npm install --global npm@11.6.2 + test "$(npm --version)" = "11.6.2" - name: Install dependencies run: pnpm install --frozen-lockfile @@ -258,6 +250,12 @@ jobs: cancel-in-progress: false steps: + - name: Require main branch + if: ${{ github.ref != 'refs/heads/main' }} + run: | + echo "::error::Publish bb-app nightly must run from the main branch, got ${GITHUB_REF}." + exit 1 + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -273,18 +271,10 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org - - name: Ensure npm supports trusted publishing + - name: Install pinned npm for trusted publishing run: | - npm_version="$(npm --version)" - node - "$npm_version" <<'NODE' || npm install --global npm@latest - const version = process.argv[2]; - const [major, minor, patch] = version.split(".").map(Number); - const supported = - major > 11 || - (major === 11 && (minor > 5 || (minor === 5 && patch >= 1))); - process.exit(supported ? 0 : 1); - NODE - npm --version + npm install --global npm@11.6.2 + test "$(npm --version)" = "11.6.2" - name: Install dependencies run: pnpm install --frozen-lockfile @@ -406,18 +396,10 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org - - name: Ensure npm supports trusted publishing + - name: Install pinned npm for trusted publishing run: | - npm_version="$(npm --version)" - node - "$npm_version" <<'NODE' || npm install --global npm@latest - const version = process.argv[2]; - const [major, minor, patch] = version.split(".").map(Number); - const supported = - major > 11 || - (major === 11 && (minor > 5 || (minor === 5 && patch >= 1))); - process.exit(supported ? 0 : 1); - NODE - npm --version + npm install --global npm@11.6.2 + test "$(npm --version)" = "11.6.2" - name: Install dependencies run: pnpm install --frozen-lockfile @@ -512,6 +494,14 @@ jobs: CSC_IDENTITY_AUTO_DISCOVERY: "false" steps: + - name: Require main branch for manual nightly release + if: >- + ${{ github.event_name == 'workflow_dispatch' + && github.ref != 'refs/heads/main' }} + run: | + echo "::error::Manual nightly desktop release must run from the main branch, got ${GITHUB_REF}." + exit 1 + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -647,6 +637,14 @@ jobs: BB_DESKTOP_RELEASE_CHANNEL: nightly steps: + - name: Require main branch for manual nightly release + if: >- + ${{ github.event_name == 'workflow_dispatch' + && github.ref != 'refs/heads/main' }} + run: | + echo "::error::Manual nightly desktop release must run from the main branch, got ${GITHUB_REF}." + exit 1 + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -767,6 +765,8 @@ jobs: timeout-minutes: 20 permissions: contents: write + id-token: write + attestations: write # The workflow-level group splits nightly from ref, so a release run and the # cron can reach this job at the same time. Both delete every asset on the # moving release and upload their own, so a race can leave one platform's @@ -776,6 +776,14 @@ jobs: cancel-in-progress: false steps: + - name: Require main branch for manual nightly publication + if: >- + ${{ github.event_name == 'workflow_dispatch' + && github.ref != 'refs/heads/main' }} + run: | + echo "::error::Manual nightly desktop publication must run from the main branch, got ${GITHUB_REF}." + exit 1 + - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -808,6 +816,15 @@ jobs: name: bb-nightly-desktop-linux-x64-${{ needs.nightly-desktop-linux.outputs.version }} path: release/linux + - name: Attest nightly desktop release binaries + id: attest + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + with: + subject-path: | + release/macos/*.dmg + release/macos/*.zip + release/linux/*.AppImage + - name: Publish moving nightly desktop release env: GH_TOKEN: ${{ github.token }} @@ -882,12 +899,14 @@ jobs: - name: Summarize nightly desktop release env: + ATTESTATION_URL: ${{ steps.attest.outputs.attestation-url }} VERSION: ${{ needs.nightly-desktop-macos.outputs.version }} run: | { echo "## bb Nightly desktop" echo echo "- version: \`$VERSION\`" + echo "- binary provenance attestation: \`${ATTESTATION_URL}\`" echo "- app identity: \`dev.bb.desktop.nightly\`" echo "- platforms: macOS arm64 (signed), Linux x64 AppImage" echo "- release: https://github.com/get-bb/bb/releases/tag/desktop-nightly" diff --git a/README.md b/README.md index 0792800f9a..733902f949 100644 --- a/README.md +++ b/README.md @@ -143,17 +143,9 @@ Then open `https://..ts.net`. Source dev binds both the Vite app and main server to loopback by default; Vite continues to proxy API and WebSocket traffic. -For direct access at `http://:` instead, run: - -```bash -pnpm dev:remote -``` - -This binds the Vite app and main server to all IPv4 interfaces. The remote -browser must be able to reach both the printed app and server ports for realtime -updates. The server API is unauthenticated and permits command execution and -file reads, so use this only behind a trusted network boundary and restrict the -ports to Tailscale traffic with the host firewall when the LAN is not trusted. +Remote access must keep the server on loopback. Use the Tailscale Serve +route above or bb connect; direct LAN/tailnet binding is not supported because +the server API is unauthenticated and permits command execution and file reads. To use the component storybook from another machine, run: diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2dac62382a..7ad3996c3e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -105,26 +105,19 @@ CI builds Linux artifacts on the pinned `ubuntu-22.04` runner. The AppImage links against the build machine's glibc, so that pin sets the oldest distribution that can run a published build. Raise it deliberately. -Linux gets both update paths, but they are not equivalent: - -- The JSON version feed (`desktop-version-linux.json`) is polled on every Linux - install and reports that a newer release exists. -- Self-installing auto-update runs only inside an AppImage whose directory the - app can write to. electron-updater detects the AppImage through the `APPIMAGE` - environment variable, and its install step unlinks the running file _before_ - moving the replacement in — so a read-only directory would delete the app and - leave nothing behind. Both the startup check and the install handler verify - write and search access on the parent directory first. -- Everything else — an extracted directory, a distribution package, or an - AppImage in a read-only location — reports new versions without installing - them. - -The Linux AppImage is unsigned, and electron-updater performs no signature -check on Linux: it verifies only the SHA-512 recorded in the update metadata -that ships beside it. macOS installs through Squirrel, which additionally -requires the replacement to satisfy the running app's code-signing -requirement. Write access to the release assets is therefore sufficient to -push code to Linux clients. Treat the release token accordingly. +Linux only uses the JSON version feed (`desktop-version-linux.json`), which is +polled to report that a newer release exists. Self-installing Linux updates are +disabled until the updater can verify publisher provenance independently of the +feed metadata. Download a replacement manually only after verifying the +published asset and its GitHub Artifact Attestation. + +The Linux AppImage is unsigned. The release publisher creates a GitHub Artifact +Attestation for desktop binaries; verify a downloaded asset with `gh attestation +verify -R get-bb/bb`. That attestation is Sigstore-signed publisher +provenance, not a Linux code signature. Linux self-installing updates remain +disabled, while macOS installs through Squirrel, which additionally requires +the replacement to satisfy the running app's code-signing requirement. Treat +release-asset write access as sufficient to push code to Linux clients. ## Releasing diff --git a/apps/desktop/src/desktop-update-provider.ts b/apps/desktop/src/desktop-update-provider.ts index d802b4751a..bd053af409 100644 --- a/apps/desktop/src/desktop-update-provider.ts +++ b/apps/desktop/src/desktop-update-provider.ts @@ -71,23 +71,13 @@ export const DESKTOP_AUTO_UPDATE_FEED_CONFIG: DesktopAutoUpdateFeedConfig = { }; interface DesktopUpdateSupport { - /** - * electron-updater can download a replacement build and install it. Linux - * only qualifies inside an AppImage, which is the one Linux target that can - * replace its own file in place. - */ + /** Whether the app may download and install a replacement automatically. */ autoUpdate: boolean; /** The JSON version feed can be polled to tell the user a release exists. */ versionCheck: boolean; } interface ResolveDesktopUpdateSupportArgs { - /** - * Whether the AppImage at this path can actually be replaced in place. - * Injected so the decision stays testable without touching a real file. - */ - canReplaceAppImage: (appImagePath: string) => boolean; - env: NodeJS.ProcessEnv; platform: BbDesktopVersionFeedPlatform; } @@ -98,18 +88,8 @@ export function resolveDesktopUpdateSupport( return { autoUpdate: true, versionCheck: true }; } - // A distro package or an extracted directory cannot rewrite itself, so those - // Linux installs still learn that a release exists but never self-install. - const appImagePath = args.env.APPIMAGE?.trim() ?? ""; - if (appImagePath.length === 0) { - return { autoUpdate: false, versionCheck: true }; - } - - // electron-updater's AppImage install unlinks the running file before it - // moves the replacement in, so a read-only directory destroys the user's - // install instead of failing harmlessly. Never offer the install path there. - return { - autoUpdate: args.canReplaceAppImage(appImagePath), - versionCheck: true, - }; + // Linux AppImage updates have no publisher signature check. Keep the feed + // available so users can see and manually verify releases, but do not let + // electron-updater download and replace executable code automatically. + return { autoUpdate: false, versionCheck: true }; } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 6c65429b83..58502d3684 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2278,8 +2278,6 @@ async function runDesktopApp(): Promise { }); const desktopUpdateSupport = resolveDesktopUpdateSupport({ - canReplaceAppImage, - env: process.env, platform: desktopPlatform, }); desktopUpdateService = createDesktopUpdateService({ diff --git a/apps/desktop/src/server-target.ts b/apps/desktop/src/server-target.ts index d5a33b0eca..aeba58c283 100644 --- a/apps/desktop/src/server-target.ts +++ b/apps/desktop/src/server-target.ts @@ -1,4 +1,5 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { validateServerUrl } from "@bb/config/public-url"; import { dirname } from "node:path"; import { z } from "zod"; @@ -96,7 +97,9 @@ export function normalizeCustomServerUrl(rawUrl: string): string | null { } catch { return null; } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + try { + validateServerUrl("server URL", trimmed); + } catch { return null; } parsed.hash = ""; diff --git a/apps/desktop/src/server-url-dialog.ts b/apps/desktop/src/server-url-dialog.ts index 3014e83b7a..fac8eb4095 100644 --- a/apps/desktop/src/server-url-dialog.ts +++ b/apps/desktop/src/server-url-dialog.ts @@ -180,7 +180,10 @@ export function openServerUrlDialog( } const parsed = serverUrlDialogSubmitRequestSchema.safeParse(payload); if (!parsed.success) { - return { ok: false, message: "Enter a valid http(s) URL." }; + return { + ok: false, + message: "Enter an HTTPS URL, or an HTTP loopback URL.", + }; } if (parsed.data.url.trim().length === 0) { finish({ kind: "clear" }); @@ -188,7 +191,10 @@ export function openServerUrlDialog( } const normalized = normalizeCustomServerUrl(parsed.data.url); if (normalized === null) { - return { ok: false, message: "Enter a valid http(s) URL." }; + return { + ok: false, + message: "Enter an HTTPS URL, or an HTTP loopback URL.", + }; } finish({ kind: "set", url: normalized }); return { ok: true }; diff --git a/apps/desktop/test/desktop-update-provider.test.ts b/apps/desktop/test/desktop-update-provider.test.ts index d037a7fc2f..0d130f6958 100644 --- a/apps/desktop/test/desktop-update-provider.test.ts +++ b/apps/desktop/test/desktop-update-provider.test.ts @@ -17,80 +17,20 @@ describe("desktop update feed url", () => { }); }); -const APP_IMAGE_PATH = "/home/user/Apps/bb-0.37.0-x86_64.AppImage"; -const alwaysReplaceable = () => true; -const neverReplaceable = () => false; - describe("desktop update support", () => { it("enables both update paths on macOS", () => { expect( resolveDesktopUpdateSupport({ - canReplaceAppImage: neverReplaceable, - env: {}, platform: "macos", }), ).toEqual({ autoUpdate: true, versionCheck: true }); }); - it("installs updates on Linux only inside an AppImage", () => { - // electron-updater replaces the running AppImage file in place. A distro - // package or an extracted directory has nothing to replace, so it would - // fail every download instead of quietly doing nothing. - expect( - resolveDesktopUpdateSupport({ - canReplaceAppImage: alwaysReplaceable, - env: { APPIMAGE: APP_IMAGE_PATH }, - platform: "linux", - }), - ).toEqual({ autoUpdate: true, versionCheck: true }); + it("keeps Linux version checks but disables self-installation", () => { expect( resolveDesktopUpdateSupport({ - canReplaceAppImage: alwaysReplaceable, - env: {}, platform: "linux", }), ).toEqual({ autoUpdate: false, versionCheck: true }); - expect( - resolveDesktopUpdateSupport({ - canReplaceAppImage: alwaysReplaceable, - env: { APPIMAGE: " " }, - platform: "linux", - }), - ).toEqual({ autoUpdate: false, versionCheck: true }); - }); - - it("refuses to install into an AppImage it cannot replace", () => { - // electron-updater unlinks the running AppImage before moving the new one - // in, so attempting this in a read-only directory deletes the user's app - // and leaves nothing behind. Version checks must survive that. - const checked: Array = []; - - expect( - resolveDesktopUpdateSupport({ - canReplaceAppImage: (path) => { - checked.push(path); - return false; - }, - env: { APPIMAGE: APP_IMAGE_PATH }, - platform: "linux", - }), - ).toEqual({ autoUpdate: false, versionCheck: true }); - expect(checked).toEqual([APP_IMAGE_PATH]); - }); - - it("does not consult the filesystem on macOS", () => { - // macOS installs through Squirrel, which owns its own replacement path. - let consulted = false; - - resolveDesktopUpdateSupport({ - canReplaceAppImage: () => { - consulted = true; - return true; - }, - env: { APPIMAGE: APP_IMAGE_PATH }, - platform: "macos", - }); - - expect(consulted).toBe(false); }); }); diff --git a/apps/desktop/test/remote-server-load.test.ts b/apps/desktop/test/remote-server-load.test.ts index cb2acab621..99839edf50 100644 --- a/apps/desktop/test/remote-server-load.test.ts +++ b/apps/desktop/test/remote-server-load.test.ts @@ -28,8 +28,7 @@ function createElectronLoadError(url: string): Error { function createHarness( overrides: Partial = {}, ): TestHarness { - const serverUrl = - overrides.serverUrl ?? "http://bb-host.tailnet.ts.net:38886"; + const serverUrl = overrides.serverUrl ?? "https://bb-host.tailnet.ts.net"; const shownErrors: StartupErrorView[] = []; const warnings: string[] = []; return { @@ -59,7 +58,7 @@ describe("loadRemoteServerPage", () => { expect(harness.shownErrors).toHaveLength(1); const view = harness.shownErrors[0]; expect(view?.title).toBe("Could not reach this bb server"); - expect(view?.details).toContain("http://bb-host.tailnet.ts.net:38886"); + expect(view?.details).toContain("https://bb-host.tailnet.ts.net"); expect(view?.details).toContain("Window ▸ Server"); expect(view?.details).toContain("This Mac"); expect(view?.details).not.toContain("rejectAndCleanup"); diff --git a/apps/desktop/test/server-target.test.ts b/apps/desktop/test/server-target.test.ts index 6106d8a2ea..98d890ed1e 100644 --- a/apps/desktop/test/server-target.test.ts +++ b/apps/desktop/test/server-target.test.ts @@ -31,13 +31,14 @@ function createMemoryFs(initial: Record = {}): { } describe("normalizeCustomServerUrl", () => { - it("trims, strips hashes and trailing slashes, rejects non-http", () => { + it("trims, strips hashes and trailing slashes, and requires secure transport", () => { expect(normalizeCustomServerUrl(" https://example.com/ ")).toBe( "https://example.com", ); - expect(normalizeCustomServerUrl("http://10.0.0.5:38886/#x")).toBe( - "http://10.0.0.5:38886", + expect(normalizeCustomServerUrl("http://127.0.0.1:38886/#x")).toBe( + "http://127.0.0.1:38886", ); + expect(normalizeCustomServerUrl("http://10.0.0.5:38886/#x")).toBeNull(); expect(normalizeCustomServerUrl("")).toBeNull(); expect(normalizeCustomServerUrl("example.com")).toBeNull(); expect(normalizeCustomServerUrl("file:///etc/passwd")).toBeNull(); diff --git a/apps/host-daemon/src/app.test.ts b/apps/host-daemon/src/app.test.ts index c9e869f3ba..909e9f33f3 100644 --- a/apps/host-daemon/src/app.test.ts +++ b/apps/host-daemon/src/app.test.ts @@ -174,11 +174,15 @@ function createFetchRecorder( }); } - if (/^\/internal\/plugins\/[^/]+\/host\/[a-f0-9]{64}$/u.test(url.pathname)) { + if ( + /^\/internal\/plugins\/[^/]+\/host\/[a-f0-9]{64}$/u.test(url.pathname) + ) { // The bridge artifact every bridge launch in these tests names. return new Response(new Uint8Array(DISPATCH_TEST_ARTIFACT_BYTES), { status: 200, - headers: { "content-length": String(DISPATCH_TEST_ARTIFACT_BYTES.byteLength) }, + headers: { + "content-length": String(DISPATCH_TEST_ARTIFACT_BYTES.byteLength), + }, }); } @@ -808,7 +812,7 @@ describe("createHostDaemonApp", () => { } }); - it("logs raw stderr for unexpected provider process exits", async () => { + it("redacts stderr for unexpected provider process exits", async () => { const { app, logger, runtimeOptions } = await createAppFixture(); try { const workspacePath = await makeTempDir( @@ -836,7 +840,7 @@ describe("createHostDaemonApp", () => { code: 1, expected: false, signal: null, - stderr: "OPENAI_API_KEY=sk-test-secret\nUsage limit reached.", + stderr: "OPENAI_API_KEY=sk-example-secret\nUsage limit reached.", }); expect(logger.warn).toHaveBeenCalledWith( @@ -845,7 +849,7 @@ describe("createHostDaemonApp", () => { threadIds: ["thr_provider_exit_log"], code: 1, signal: null, - stderr: "OPENAI_API_KEY=sk-test-secret\nUsage limit reached.", + stderr: "OPENAI_API_KEY=[REDACTED]\nUsage limit reached.", }, "Unexpected provider process exited with stderr", ); diff --git a/apps/host-daemon/src/app.ts b/apps/host-daemon/src/app.ts index 6026245d66..1b39dbb79b 100644 --- a/apps/host-daemon/src/app.ts +++ b/apps/host-daemon/src/app.ts @@ -13,6 +13,7 @@ import { startEventLoopStallMonitor } from "./event-loop-stall-monitor.js"; import { startHostDaemonHealthMonitor } from "./host-daemon-health-monitor.js"; import { startLocalApiServer, type LocalApiServer } from "./local-api.js"; import type { HostDaemonLocalApiConfig } from "./local-api-config.js"; +import { ensurePrivateDirectory, redactSensitiveText } from "@bb/logger"; import type { HostDaemonLogger } from "./logger.js"; import type { HostDaemonDaemonWsMessage } from "@bb/host-daemon-contract"; import { @@ -227,6 +228,7 @@ interface MaybeInvalidateSessionArgs { export async function createHostDaemonApp( options: CreateHostDaemonAppOptions, ): Promise { + ensurePrivateDirectory(options.dataDir); const threadStorageRootPath = await ensureThreadStorageRoot(options.dataDir); const dataDirSkillsRootPath = await ensureDataDirSkillsRootPath( options.dataDir, @@ -622,14 +624,16 @@ export async function createHostDaemonApp( }, onProcessExit: (info) => { const threadIds = info.threads.map((thread) => thread.threadId); - if (!info.expected && info.stderr) { + const stderr = + info.stderr === null ? null : redactSensitiveText(info.stderr); + if (!info.expected && stderr) { options.logger.warn( { providerId: info.providerId, threadIds, code: info.code, signal: info.signal, - stderr: info.stderr, + stderr, }, "Unexpected provider process exited with stderr", ); diff --git a/apps/host-daemon/src/command-router.ts b/apps/host-daemon/src/command-router.ts index 6ea80d11c1..6b0af563f5 100644 --- a/apps/host-daemon/src/command-router.ts +++ b/apps/host-daemon/src/command-router.ts @@ -26,6 +26,7 @@ import { } from "./command-dispatch.js"; import { isExpectedOnlineRpcFailureError } from "./command-dispatch-support.js"; import { roundDurationMs } from "./event-loop-stall-monitor.js"; +import { redactSensitiveText } from "@bb/logger"; import type { HostDaemonLogger } from "./logger.js"; import { RuntimeManager } from "./runtime-manager.js"; import type { PluginHostManager } from "./plugin-host-manager.js"; @@ -144,13 +145,15 @@ export class CommandRouter { handlerMs: elapsedMs(handlerStartedAtMs), ok: false, }); + const errorMessage = + error instanceof Error ? error.message : String(error); return { type: "host-rpc.response", requestId: message.requestId, commandType: message.command.type, ok: false, errorCode, - errorMessage: error instanceof Error ? error.message : String(error), + errorMessage: redactSensitiveText(errorMessage), }; } } diff --git a/apps/host-daemon/src/index.ts b/apps/host-daemon/src/index.ts index c08d2b8d92..57e0b82ea2 100644 --- a/apps/host-daemon/src/index.ts +++ b/apps/host-daemon/src/index.ts @@ -3,6 +3,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { loadHostDaemonStartConfig } from "@bb/config/host-daemon"; import { loadHostDaemonEntrypointConfig } from "@bb/config/host-daemon-entrypoint"; +import { ensurePrivateDirectory } from "@bb/logger"; import { installSafeProcessDiagnostics, writeSafeProcessDiagnosticReport, @@ -82,6 +83,7 @@ const isMainModule = if (isMainModule) { const diagnosticsLogsDir = resolveDiagnosticsLogsDir(); + ensurePrivateDirectory(diagnosticsLogsDir); installSafeProcessDiagnostics({ logsDir: diagnosticsLogsDir, processName: "host-daemon", diff --git a/apps/host-daemon/src/injected-skills.ts b/apps/host-daemon/src/injected-skills.ts index 87b9cddcbc..67786eda45 100644 --- a/apps/host-daemon/src/injected-skills.ts +++ b/apps/host-daemon/src/injected-skills.ts @@ -202,7 +202,11 @@ export async function ensureDataDirSkillsRootPath( dataDir: string, ): Promise { const dataDirSkillsRootPath = resolveDataDirSkillsRootPath(dataDir); - await fs.mkdir(dataDirSkillsRootPath, { recursive: true }); + await fs.mkdir(dataDirSkillsRootPath, { + mode: 0o700, + recursive: true, + }); + await fs.chmod(dataDirSkillsRootPath, 0o700); return dataDirSkillsRootPath; } diff --git a/apps/host-daemon/src/lock.test.ts b/apps/host-daemon/src/lock.test.ts index 2338ea9aea..4021d4a880 100644 --- a/apps/host-daemon/src/lock.test.ts +++ b/apps/host-daemon/src/lock.test.ts @@ -46,6 +46,15 @@ describe("acquireDaemonLock compromise handling", () => { await fs.rm(dataDir, { recursive: true, force: true }); }); + it("repairs the data directory to owner-only permissions", async () => { + await fs.chmod(dataDir, 0o755); + + const release = await acquireDaemonLock(dataDir); + + expect((await fs.stat(dataDir)).mode & 0o777).toBe(0o700); + await release(); + }); + it("re-acquires a compromised lock instead of crashing the daemon", async () => { const { logger, warnings } = createRecordingLogger(); const onLockLost = vi.fn(); diff --git a/apps/host-daemon/src/lock.ts b/apps/host-daemon/src/lock.ts index 4eee60adf1..59bfdcf4dc 100644 --- a/apps/host-daemon/src/lock.ts +++ b/apps/host-daemon/src/lock.ts @@ -53,7 +53,8 @@ export async function acquireDaemonLock( dataDir: string, options: AcquireDaemonLockOptions = {}, ): Promise<() => Promise> { - await fs.mkdir(dataDir, { recursive: true }); + await fs.mkdir(dataDir, { mode: 0o700, recursive: true }); + await fs.chmod(dataDir, 0o700); const lockPath = path.join(dataDir, DAEMON_LOCK_FILE_NAME); await fs.writeFile(lockPath, "", { encoding: "utf8", flag: "a" }); diff --git a/apps/host-daemon/src/plugin-host-manager.test.ts b/apps/host-daemon/src/plugin-host-manager.test.ts index b99d8c8557..793c197ad1 100644 --- a/apps/host-daemon/src/plugin-host-manager.test.ts +++ b/apps/host-daemon/src/plugin-host-manager.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { HostDaemonOnlineRpcCommand } from "@bb/host-daemon-contract"; import type { WatchPathRootArgs } from "@bb/host-watcher"; -import { sanitizeInheritedChildProcessEnv } from "@bb/process-utils"; +import { sanitizePluginProcessEnv } from "@bb/process-utils"; import { afterEach, describe, expect, it, vi } from "vitest"; import { PluginHostManager } from "./plugin-host-manager.js"; @@ -31,7 +31,9 @@ export default { experimental_apiVersion: 1, contract: { echo: { input: anySchema, output: anySchema }, + env: { input: anySchema, output: anySchema }, wait: { input: anySchema, output: anySchema }, + stderr: { input: anySchema, output: anySchema }, crash: { input: anySchema, output: anySchema }, stringEcho: { input: stringSchema, output: stringSchema }, invalidOutput: { input: anySchema, output: stringSchema }, @@ -44,11 +46,21 @@ export default { experimental_signals: { changed: { payload: anySchema } }, handlers: { echo(input) { return { input, pid: process.pid }; }, + env() { + return { + CODEX_HOME: process.env.CODEX_HOME ?? "missing", + GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? "missing", + HOME: process.env.HOME ?? "missing", + OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "missing", + PATH: process.env.PATH ?? "missing", + }; + }, wait(_input, context) { return new Promise((resolve) => { context.signal.addEventListener("abort", () => resolve({ aborted: true }), { once: true }); }); }, + stderr(input) { process.stderr.write(String(input.text) + String.fromCharCode(10)); return { ok: true }; }, crash() { process.exit(17); }, stringEcho(input) { return input; }, invalidOutput() { return { nope: true }; }, @@ -129,6 +141,7 @@ describe("PluginHostManager", () => { .splice(0) .map((dir) => rm(dir, { recursive: true, force: true })), ); + vi.unstubAllEnvs(); }); async function createManager( @@ -238,6 +251,35 @@ describe("PluginHostManager", () => { expect(logger.warn).not.toHaveBeenCalled(); }); + it("redacts host plugin stderr before logging it", async () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + const manager = await createManager({ logger }); + const credential = "sk-example-host-stderr"; + + await manager.call( + callCommand({ + method: "stderr", + input: { text: `OPENAI_API_KEY=${credential}` }, + }), + ); + + await vi.waitFor(() => + expect(logger.warn).toHaveBeenCalledWith( + { + pluginId: "fixture", + origin: "host", + stderr: "OPENAI_API_KEY=[REDACTED]", + }, + "Host plugin stderr", + ), + ); + expect(JSON.stringify(logger.warn.mock.calls)).not.toContain(credential); + }); + it("bounds call count and input bytes while worker startup is pending", async () => { let resolveCountFetch!: (bytes: Uint8Array) => void; const countFetch = vi.fn( @@ -631,16 +673,38 @@ describe("PluginHostManager", () => { ), ).rejects.toThrow(/changed artifact digest/u); }); + + it("does not expose ambient secret-shaped variables to the worker", async () => { + vi.stubEnv("CODEX_HOME", "/Users/test/.codex"); + vi.stubEnv("GITHUB_TOKEN", "ambient-github-secret"); + vi.stubEnv("HOME", "/Users/test"); + vi.stubEnv("OPENAI_API_KEY", "ambient-openai-secret"); + + const manager = await createManager({ + shellEnv: () => ({ PATH: "/Users/test/bin:/usr/bin" }), + }); + const result = await manager.call( + callCommand({ method: "env", input: {} }), + ); + + expect(result.output).toEqual({ + CODEX_HOME: "/Users/test/.codex", + GITHUB_TOKEN: "missing", + HOME: "/Users/test", + OPENAI_API_KEY: "missing", + PATH: "/Users/test/bin:/usr/bin", + }); + }); }); describe("host plugin worker env", () => { - it("uses the login-shell PATH without forwarding daemon BB variables", () => { + it("uses the login-shell PATH without forwarding ambient secrets", () => { expect( - sanitizeInheritedChildProcessEnv({ + sanitizePluginProcessEnv({ env: { HOME: "/Users/test", PATH: "/usr/bin", - GH_TOKEN: "user-token", + GH_TOKEN: "ambient-secret", BB_CONNECT_MACHINE_CREDENTIAL: "daemon-secret", BB_SERVER_URL: "http://daemon.internal", }, @@ -649,7 +713,6 @@ describe("host plugin worker env", () => { ).toEqual({ HOME: "/Users/test", PATH: "/Users/test/bin:/usr/bin", - GH_TOKEN: "user-token", }); }); }); diff --git a/apps/host-daemon/src/plugin-host-manager.ts b/apps/host-daemon/src/plugin-host-manager.ts index 3deb2bdc1c..6c6fcbd0bc 100644 --- a/apps/host-daemon/src/plugin-host-manager.ts +++ b/apps/host-daemon/src/plugin-host-manager.ts @@ -14,8 +14,9 @@ import { jsonValueSchema, type JsonValue } from "@bb/domain"; import { createPluginProcessTempDir, ensurePluginProcessDataDir, - sanitizeInheritedChildProcessEnv, + sanitizePluginProcessEnv, } from "@bb/process-utils"; +import { redactSensitiveText } from "@bb/logger"; import type { HostDaemonLogger } from "./logger.js"; import { ensureCachedPluginHostArtifact } from "./plugin-host-artifact-cache.js"; @@ -174,7 +175,9 @@ function defaultWorkerEntryPath(): string { } function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); + return redactSensitiveText( + error instanceof Error ? error.message : String(error), + ); } function observeBoundedStderr( @@ -206,7 +209,7 @@ function observeBoundedStderr( return; } emittedLines += 1; - onLine(tail.toString("utf8").replace(/\r$/u, "")); + onLine(redactSensitiveText(tail.toString("utf8").replace(/\r$/u, ""))); tail = Buffer.alloc(0); }; source.on("data", (chunk: Buffer) => { @@ -465,9 +468,11 @@ export class PluginHostManager { defaultWorkerEntryPath(), [artifactPath, command.pluginId, command.generation, dataDir, tempDir], { - // Same answer every daemon-spawned child gets, plus the user's - // login-shell PATH so a host plugin can find their executables. - env: sanitizeInheritedChildProcessEnv({ + // Host workers get only the process-execution baseline, plus the + // user's login-shell PATH so a host plugin can find executables. + // Plugin-specific credentials must be overlaid explicitly by the + // caller; never hand the worker the daemon's ambient env. + env: sanitizePluginProcessEnv({ env: process.env, ...(shellPath !== undefined ? { shellPath } : {}), }), @@ -598,7 +603,7 @@ export class PluginHostManager { } if (record.type === "startup-error" && typeof record.error === "string") { clearTimeout(startTimer); - failWorker(record.error); + failWorker(redactSensitiveText(record.error)); return; } if ( @@ -797,7 +802,11 @@ export class PluginHostManager { watchId: string, error: string, ): void { - sendToWorker(worker.child, { type: "watch-start-error", watchId, error }); + sendToWorker(worker.child, { + type: "watch-start-error", + watchId, + error: redactSensitiveText(error), + }); } private queueWorkerWatchChanges( @@ -946,7 +955,7 @@ export class PluginHostManager { pending.reject( new Error( typeof result.error === "string" - ? result.error + ? redactSensitiveText(result.error) : "host handler failed", ), ); diff --git a/apps/host-daemon/src/protocol-self-update.test.ts b/apps/host-daemon/src/protocol-self-update.test.ts index 8988bc21bb..cafdb82844 100644 --- a/apps/host-daemon/src/protocol-self-update.test.ts +++ b/apps/host-daemon/src/protocol-self-update.test.ts @@ -54,7 +54,7 @@ async function createFixture( const testLogger = logger(); const updater = createProtocolSelfUpdater({ dataDir, - enabled: args.enabled ?? true, + enabled: args.enabled ?? false, fetchFn, ...(args.useDefaultInstaller ? { runProcess } : { installTarball }), logger: testLogger, @@ -73,7 +73,7 @@ afterEach(async () => { describe("protocol self-update", () => { it("installs exactly once when the server protocol is newer and enabled", async () => { - const test = await createFixture(); + const test = await createFixture({ enabled: true }); await expect(test.updater.handleProtocolMismatch()).resolves.toBe( "updated", ); @@ -83,7 +83,10 @@ describe("protocol self-update", () => { it("finds npm beside the running Node executable when the service PATH omits it", async () => { vi.stubEnv("PATH", "/usr/bin:/bin"); - const test = await createFixture({ useDefaultInstaller: true }); + const test = await createFixture({ + enabled: true, + useDefaultInstaller: true, + }); await expect(test.updater.handleProtocolMismatch()).resolves.toBe( "updated", @@ -108,7 +111,10 @@ describe("protocol self-update", () => { it("updates an installer-managed bb-app inside its machine-specific prefix", async () => { vi.stubEnv("BB_APP_NPM_PREFIX", "/machine-data/npm"); - const test = await createFixture({ useDefaultInstaller: true }); + const test = await createFixture({ + enabled: true, + useDefaultInstaller: true, + }); await expect(test.updater.handleProtocolMismatch()).resolves.toBe( "updated", @@ -130,7 +136,10 @@ describe("protocol self-update", () => { it("keeps legacy global updates when the installer prefix is blank", async () => { vi.stubEnv("BB_APP_NPM_PREFIX", " "); - const test = await createFixture({ useDefaultInstaller: true }); + const test = await createFixture({ + enabled: true, + useDefaultInstaller: true, + }); await expect(test.updater.handleProtocolMismatch()).resolves.toBe( "updated", @@ -148,8 +157,8 @@ describe("protocol self-update", () => { ); }); - it("does nothing when auto-update is disabled", async () => { - const test = await createFixture({ enabled: false }); + it("does nothing when auto-update is disabled by default", async () => { + const test = await createFixture(); await expect(test.updater.handleProtocolMismatch()).resolves.toBe( "skipped", ); @@ -159,6 +168,7 @@ describe("protocol self-update", () => { it("refuses auto-update over non-loopback HTTP", async () => { const test = await createFixture({ + enabled: true, serverUrl: "http://server.example.test", }); await expect(test.updater.handleProtocolMismatch()).resolves.toBe("failed"); @@ -171,7 +181,10 @@ describe("protocol self-update", () => { }); it("allows auto-update over loopback HTTP", async () => { - const test = await createFixture({ serverUrl: "http://127.0.0.1:38886" }); + const test = await createFixture({ + enabled: true, + serverUrl: "http://127.0.0.1:38886", + }); await expect(test.updater.handleProtocolMismatch()).resolves.toBe( "updated", ); @@ -183,7 +196,7 @@ describe("protocol self-update", () => { HOST_DAEMON_PROTOCOL_VERSION, HOST_DAEMON_PROTOCOL_VERSION - 1, ]) { - const test = await createFixture({ protocolVersion }); + const test = await createFixture({ enabled: true, protocolVersion }); await expect(test.updater.handleProtocolMismatch()).resolves.toBe( "skipped", ); @@ -193,7 +206,7 @@ describe("protocol self-update", () => { it("persists a short exponential retry backoff capped at five minutes", async () => { let now = 10_000; - const test = await createFixture({ now: () => now }); + const test = await createFixture({ enabled: true, now: () => now }); await expect(test.updater.handleProtocolMismatch()).resolves.toBe( "updated", ); @@ -227,6 +240,7 @@ describe("protocol self-update", () => { it("contains install failures and rate-limits their retry", async () => { const test = await createFixture({ + enabled: true, installFailure: new Error("npm failed"), now: () => 25_000, }); @@ -244,6 +258,7 @@ describe("protocol self-update", () => { it("lets a user-requested retry bypass and reset the current backoff", async () => { let now = 25_000; const test = await createFixture({ + enabled: true, installFailure: new Error("download failed"), now: () => now, }); @@ -265,7 +280,7 @@ describe("protocol self-update", () => { it("tries immediately when the server advances to another protocol", async () => { let now = 30_000; let protocolVersion = HOST_DAEMON_PROTOCOL_VERSION + 1; - const test = await createFixture({ now: () => now }); + const test = await createFixture({ enabled: true, now: () => now }); test.fetchFn.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/install/version")) { diff --git a/apps/host-daemon/src/protocol-self-update.ts b/apps/host-daemon/src/protocol-self-update.ts index 4184c3415b..5fc43387d1 100644 --- a/apps/host-daemon/src/protocol-self-update.ts +++ b/apps/host-daemon/src/protocol-self-update.ts @@ -45,7 +45,7 @@ interface SelfUpdateProcessRunner { interface CreateProtocolSelfUpdaterOptions { dataDir: string; - enabled: boolean; + enabled?: boolean; logger: HostDaemonLogger; serverUrl: string; fetchFn?: FetchFn; @@ -139,6 +139,8 @@ async function defaultInstallTarball( tarballPath: string, runProcess: SelfUpdateProcessRunner, ): Promise { + // No publisher signature or digest-distribution contract exists yet. This + // installer is only reachable after the explicit --auto-update opt-in. const executableDirectory = dirname(process.execPath); const inheritedPath = process.env.PATH; const path = inheritedPath @@ -198,7 +200,10 @@ export function createProtocolSelfUpdater( try { const versionUrl = new URL("/install/version", options.serverUrl); - const versionResponse = await fetchFn(versionUrl, { method: "GET" }); + const versionResponse = await fetchFn(versionUrl, { + method: "GET", + redirect: "error", + }); if (!versionResponse.ok) { throw new Error( `Version check failed: ${versionResponse.status} ${versionResponse.statusText}`, @@ -261,7 +266,10 @@ export function createProtocolSelfUpdater( ); try { const tarballUrl = new URL("/install/bb-app.tgz", options.serverUrl); - const response = await fetchFn(tarballUrl, { method: "GET" }); + const response = await fetchFn(tarballUrl, { + method: "GET", + redirect: "error", + }); if (!response.ok) { throw new Error( `Package download failed: ${response.status} ${response.statusText}`, diff --git a/apps/host-daemon/src/provider-installation.test.ts b/apps/host-daemon/src/provider-installation.test.ts index 04d4d8edcb..2f76f28be5 100644 --- a/apps/host-daemon/src/provider-installation.test.ts +++ b/apps/host-daemon/src/provider-installation.test.ts @@ -88,6 +88,38 @@ describe("streamProviderInstallation", () => { }); }); + it("redacts credential-shaped provider stderr in streamed events", async () => { + const process = fakeProcess(); + const stream = streamProviderInstallation({ + providerId: "example-provider", + plan: { command: "example", args: [], displayCommand: "example" }, + processSpawner: { spawn: () => process }, + }); + process.stderr.write("OPENAI_API_KEY=sk-example-install-secret\n"); + process.close(1); + + await expect(readEvents(stream)).resolves.toEqual([ + { + type: "started", + provider: "example-provider", + command: "example", + }, + { + type: "output", + provider: "example-provider", + stream: "stderr", + text: "OPENAI_API_KEY=[REDACTED]\n", + }, + { + type: "completed", + provider: "example-provider", + exitCode: 1, + signal: null, + success: false, + }, + ]); + }); + it("allows only one installation process at a time", async () => { const firstProcess = fakeProcess(); const first = streamProviderInstallation({ diff --git a/apps/host-daemon/src/provider-installation.ts b/apps/host-daemon/src/provider-installation.ts index d8ac0f290c..31a6f03de9 100644 --- a/apps/host-daemon/src/provider-installation.ts +++ b/apps/host-daemon/src/provider-installation.ts @@ -1,5 +1,6 @@ import { PassThrough, type Readable } from "node:stream"; import type { ProviderInstallationCommand } from "@bb/provider-bridge-protocol"; +import { redactSensitiveText } from "@bb/logger"; import { providerCliInstallEventSchema, type ProviderCliInstallEvent, @@ -128,7 +129,9 @@ export function streamProviderInstallation(args: { write({ type: "error", provider: args.providerId, - message: error instanceof Error ? error.message : String(error), + message: redactSensitiveText( + error instanceof Error ? error.message : String(error), + ), }); close(); return; @@ -148,14 +151,14 @@ export function streamProviderInstallation(args: { type: "output", provider: args.providerId, stream: "stderr", - text, + text: redactSensitiveText(text), }), ); child.onError((error) => { write({ type: "error", provider: args.providerId, - message: error.message, + message: redactSensitiveText(error.message), }); close(); }); diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts index 8fef1f9d01..a14e8a87ce 100644 --- a/apps/host-daemon/src/runtime-manager.test.ts +++ b/apps/host-daemon/src/runtime-manager.test.ts @@ -762,14 +762,16 @@ describe("RuntimeManager", () => { // pending, its catalog is staged, and nothing in `entries` names it yet. const provisionStarted = createDeferredPromise(); const releaseProvision = createDeferredPromise(); - const provisionWorkspace = vi.fn(async (options: ProvisionWorkspaceArgs) => { - const targetPath = "path" in options ? options.path : undefined; - if (targetPath === "/tmp/env-a") { - provisionStarted.resolve(); - await releaseProvision.promise; - } - return createFakeWorkspace(targetPath ?? "/tmp/env"); - }); + const provisionWorkspace = vi.fn( + async (options: ProvisionWorkspaceArgs) => { + const targetPath = "path" in options ? options.path : undefined; + if (targetPath === "/tmp/env-a") { + provisionStarted.resolve(); + await releaseProvision.promise; + } + return createFakeWorkspace(targetPath ?? "/tmp/env"); + }, + ); const manager = new RuntimeManager({ dataDir, provisionWorkspace, @@ -2087,7 +2089,7 @@ describe("RuntimeManager", () => { code: 1, expected: false, signal: null, - stderr: "OPENAI_API_KEY=sk-test-secret\nUsage limit reached.", + stderr: "OPENAI_API_KEY=sk-example-secret\nUsage limit reached.", }); expect(emittedEvents).toEqual([ @@ -2121,14 +2123,13 @@ describe("RuntimeManager", () => { scope: turnScope("turn-1"), code: "provider_process_exited", message: 'Provider "codex" exited unexpectedly with code 1', - detail: - "stderr:\nOPENAI_API_KEY=sk-test-secret\nUsage limit reached.", + detail: "stderr:\nOPENAI_API_KEY=[REDACTED]\nUsage limit reached.", }, }, ]); expect(forwardedProcessExits).toEqual([ expect.objectContaining({ - stderr: "OPENAI_API_KEY=sk-test-secret\nUsage limit reached.", + stderr: "OPENAI_API_KEY=[REDACTED]\nUsage limit reached.", }), ]); }); diff --git a/apps/host-daemon/src/runtime-manager.ts b/apps/host-daemon/src/runtime-manager.ts index 1b82b9b4ad..c3801a7d33 100644 --- a/apps/host-daemon/src/runtime-manager.ts +++ b/apps/host-daemon/src/runtime-manager.ts @@ -8,7 +8,7 @@ import { type AgentRuntimeProcessExitInfo, type ReapedIdleProviderSession, } from "@bb/agent-runtime"; -import type { Logger } from "@bb/logger"; +import { redactSensitiveText, type Logger } from "@bb/logger"; import { killProcessesWithCwdUnder } from "@bb/process-utils"; import type { PendingInteractionCreate, @@ -129,6 +129,15 @@ function buildProviderProcessExitMessage( return `Provider "${info.providerId}" exited unexpectedly with ${formatProviderProcessExitStatus(info)}`; } +function sanitizeProviderProcessExitInfo( + info: AgentRuntimeProcessExitInfo, +): AgentRuntimeProcessExitInfo { + if (info.stderr === null) { + return info; + } + return { ...info, stderr: redactSensitiveText(info.stderr) }; +} + function buildProviderProcessExitDetail( info: AgentRuntimeProcessExitInfo, ): string | undefined { @@ -1431,7 +1440,8 @@ export class RuntimeManager { success: true, })), onInteractiveRequest: this.options.onInteractiveRequest, - onStderr: this.options.onStderr, + onStderr: (line, threadId) => + this.options.onStderr?.(redactSensitiveText(line), threadId), onProcessExit: (info) => { if ( runtime && @@ -1440,7 +1450,7 @@ export class RuntimeManager { ) { this.providerMaintenanceRuntime = null; } - this.options.onProcessExit?.(info); + this.options.onProcessExit?.(sanitizeProviderProcessExitInfo(info)); }, }); return runtime; @@ -1500,7 +1510,8 @@ export class RuntimeManager { success: true, })), onInteractiveRequest: this.options.onInteractiveRequest, - onStderr: this.options.onStderr, + onStderr: (line, threadId) => + this.options.onStderr?.(redactSensitiveText(line), threadId), onProviderRecovery: (hint) => { // The runtime has already acted on the kind (unarchive-and-retry, // typed auth_required rejection, bridge restart, stale-steer drop, @@ -1522,8 +1533,11 @@ export class RuntimeManager { ); }, onProcessExit: (info) => { - if (!info.expected) { - for (const event of this.buildUnexpectedProviderExitEvents(info)) { + const sanitizedInfo = sanitizeProviderProcessExitInfo(info); + if (!sanitizedInfo.expected) { + for (const event of this.buildUnexpectedProviderExitEvents( + sanitizedInfo, + )) { this.options.onEvent?.({ environmentId: args.environmentId, event, @@ -1532,13 +1546,13 @@ export class RuntimeManager { } const current = this.entries.get(args.environmentId); if ( - !info.expected && + !sanitizedInfo.expected && current?.runtime === runtime && runtime.listRunningProviders().length === 0 ) { this.entries.delete(args.environmentId); } - this.options.onProcessExit?.(info); + this.options.onProcessExit?.(sanitizedInfo); }, }); diff --git a/apps/host-daemon/src/server-client.test.ts b/apps/host-daemon/src/server-client.test.ts index e4647ee52b..4d67122e1d 100644 --- a/apps/host-daemon/src/server-client.test.ts +++ b/apps/host-daemon/src/server-client.test.ts @@ -1,4 +1,3 @@ -import { AbortError } from "p-retry"; import { describe, expect, it, vi } from "vitest"; import type { PendingInteractionCreate } from "@bb/domain"; import { HOST_ARTIFACT_MAX_BYTES } from "@bb/host-daemon-contract/protocol"; @@ -142,45 +141,48 @@ describe("createServerClient", () => { }, ); - it("refuses to fetch project attachments over insecure non-loopback HTTP", async () => { + it("rejects non-loopback HTTP at client construction", () => { const fetchFn = vi.fn(); - const client = createServerClient({ - fetchFn, - getSessionId: () => "session-1", - hostKey: "host-key", - logger: createLogger(), - serverUrl: "http://bb.example.test", - }); - await expect( - client.fetchProjectAttachment({ - maxBytes: 25, - projectId: "project-1", - threadId: "thread-1", - path: "network-tab.har", + expect(() => + createServerClient({ + fetchFn, + getSessionId: () => "session-1", + hostKey: "host-key", + logger: createLogger(), + serverUrl: "http://bb.example.test", }), - ).rejects.toBeInstanceOf(AbortError); + ).toThrow(/HTTPS/u); expect(fetchFn).not.toHaveBeenCalled(); }); - it("refuses to fetch project attachments when the server URL is malformed", async () => { + it("rejects malformed server URLs at client construction", () => { const fetchFn = vi.fn(); - const client = createServerClient({ - fetchFn, - getSessionId: () => "session-1", - hostKey: "host-key", - logger: createLogger(), - serverUrl: "not a url", - }); - await expect( - client.fetchProjectAttachment({ - maxBytes: 25, - projectId: "project-1", - threadId: "thread-1", - path: "network-tab.har", + expect(() => + createServerClient({ + fetchFn, + getSessionId: () => "session-1", + hostKey: "host-key", + logger: createLogger(), + serverUrl: "not a url", + }), + ).toThrow(/valid URL/u); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("rejects non-HTTP server URLs at client construction", () => { + const fetchFn = vi.fn(); + + expect(() => + createServerClient({ + fetchFn, + getSessionId: () => "session-1", + hostKey: "host-key", + logger: createLogger(), + serverUrl: "ftp://127.0.0.1", }), - ).rejects.toBeInstanceOf(AbortError); + ).toThrow(/HTTPS/u); expect(fetchFn).not.toHaveBeenCalled(); }); @@ -245,7 +247,7 @@ describe("createServerClient", () => { getSessionId: () => "session-1", hostKey: "host-key", logger: createLogger(), - serverUrl: "http://192.168.1.10:3000", + serverUrl: "https://192.168.1.10:3000", }); await expect(client.fetchSkillTree(treeHash)).resolves.toEqual({ diff --git a/apps/host-daemon/src/server-client.ts b/apps/host-daemon/src/server-client.ts index 4382c02bfc..02016ec3b4 100644 --- a/apps/host-daemon/src/server-client.ts +++ b/apps/host-daemon/src/server-client.ts @@ -26,6 +26,7 @@ import { type HostDaemonSkillTree, } from "@bb/host-daemon-contract"; import { HOST_ARTIFACT_MAX_BYTES } from "@bb/host-daemon-contract/protocol"; +import { validateServerUrl } from "@bb/config/public-url"; import type { PendingInteractionCreate, ToolCallRequest } from "@bb/domain"; import type { HostDaemonLogger } from "./logger.js"; import type { EventPostResult } from "./event-sink.js"; @@ -219,9 +220,11 @@ export function usesSecureInternalFetchTransport(serverUrl: string): boolean { } return ( - parsed.hostname === "127.0.0.1" || - parsed.hostname === "localhost" || - parsed.hostname === "::1" + parsed.protocol === "http:" && + (parsed.hostname === "127.0.0.1" || + parsed.hostname === "localhost" || + parsed.hostname === "::1" || + parsed.hostname === "[::1]") ); } @@ -397,6 +400,7 @@ export async function readHostArtifactBytes( export function createServerClient( options: CreateServerClientOptions, ): ServerClient { + const serverUrl = validateServerUrl("serverUrl", options.serverUrl); const fetchFn = options.fetchFn ?? fetch; function requireSessionId(): string { @@ -421,7 +425,7 @@ export function createServerClient( pathname: string, query?: Record, ): string { - const url = new URL(`/internal${pathname}`, options.serverUrl); + const url = new URL(`/internal${pathname}`, serverUrl); if (query) { for (const [key, value] of Object.entries(query)) { if (value !== undefined) { @@ -497,9 +501,9 @@ export function createServerClient( async fetchProjectAttachment( args: FetchProjectAttachmentArgs, ): Promise { - if (!usesSecureInternalFetchTransport(options.serverUrl)) { + if (!usesSecureInternalFetchTransport(serverUrl)) { throw new AbortError( - `Refusing to fetch project attachment over insecure server URL: ${options.serverUrl}`, + `Refusing to fetch project attachment over insecure server URL: ${serverUrl}`, ); } diff --git a/apps/host-daemon/src/thread-storage-root.test.ts b/apps/host-daemon/src/thread-storage-root.test.ts index 4de448f11a..028f48bf35 100644 --- a/apps/host-daemon/src/thread-storage-root.test.ts +++ b/apps/host-daemon/src/thread-storage-root.test.ts @@ -33,6 +33,7 @@ describe("thread storage root", () => { expect(rootPath).toBe(threadStorageRootPath(dataDir)); expect(stats.isDirectory()).toBe(true); + expect(stats.mode & 0o777).toBe(0o700); }); it("ignores a parent agent thread's ambient storage path", async () => { diff --git a/apps/host-daemon/src/thread-storage-root.ts b/apps/host-daemon/src/thread-storage-root.ts index 4294bb5883..158c1b6ce6 100644 --- a/apps/host-daemon/src/thread-storage-root.ts +++ b/apps/host-daemon/src/thread-storage-root.ts @@ -9,6 +9,7 @@ export async function ensureThreadStorageRoot( dataDir: string, ): Promise { const rootPath = threadStorageRootPath(dataDir); - await fs.mkdir(rootPath, { recursive: true }); + await fs.mkdir(rootPath, { mode: 0o700, recursive: true }); + await fs.chmod(rootPath, 0o700); return rootPath; } diff --git a/apps/mobile/README.md b/apps/mobile/README.md index b22f774cf6..e79cc56307 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -323,8 +323,8 @@ EXPO_PUBLIC_BB_SERVER_URL=http://127.0.0.1: pnpm dev # Metro (dev-client The iOS Simulator shares the Mac loopback, so `pnpm dev` (repo root) or `scripts/bb-dev-app current` gives a server URL that works as-is. Physical -phones need a Tailscale Serve URL, bb connect, or a temporary -`BB_SERVER_BIND_HOST=0.0.0.0`. +phones need a Tailscale Serve URL or bb connect; direct server binding is not +supported because the API is unauthenticated. ## E2E (Maestro) diff --git a/apps/server/src/assets/install-machine.sh b/apps/server/src/assets/install-machine.sh index 251fd1a380..e4a8b59484 100755 --- a/apps/server/src/assets/install-machine.sh +++ b/apps/server/src/assets/install-machine.sh @@ -2,12 +2,18 @@ set -eu +# Enrollment data contains host credentials and provider state. Do not let a +# permissive caller umask expose newly created files or directories. +umask 077 + usage() { cat >&2 <<'EOF' -Usage: install.sh --join-code --host-id --server [--machine-code ] [--host-daemon-port ] +Usage: install.sh --join-code --host-id --server [--machine-code ] [--host-daemon-port ] [--auto-update] The first three options are required. --machine-code is required through bb connect. -By default, the installer assigns this enrolled daemon its own local API port. +By default, the installer assigns this enrolled daemon its own local API port and +leaves unattended daemon updates disabled. Pass --auto-update only after explicitly +trusting this server's unsigned bb-app package for unattended updates. EOF exit 2 } @@ -17,6 +23,7 @@ host_id= server_url= machine_code= requested_host_daemon_port= +auto_update=no CURL_CONNECT_TIMEOUT_SECONDS=10 PACKAGE_DOWNLOAD_TIMEOUT_SECONDS=300 @@ -98,6 +105,10 @@ ready_row() { while [ "$#" -gt 0 ]; do case "$1" in + --auto-update) + auto_update=yes + shift + ;; --join-code|--host-id|--server|--machine-code|--host-daemon-port) [ "$#" -ge 2 ] || usage [ -n "$2" ] || usage @@ -168,12 +179,37 @@ server_host=$(node -e ' exit 1 } service_slug=$(printf '%s' "$server_host" | tr '.' '-') +server_loopback=$(node -e ' + const url = new URL(process.argv[1]); + const hostname = url.hostname.toLowerCase(); + const loopback = + hostname === "127.0.0.1" || + hostname === "localhost" || + hostname === "[::1]"; + if (url.protocol === "https:") { + process.stdout.write("no"); + } else if (url.protocol === "http:" && loopback) { + process.stdout.write("yes"); + } else { + process.exit(1); + } +' "$server_url" 2>/dev/null) || { + fail_step "Server URL must use HTTPS; HTTP is allowed only for loopback local development." + exit 1 +} +if [ "$server_loopback" = yes ]; then + curl_allowed_protocols='=http,https' +else + curl_allowed_protocols='=https' +fi +curl_allowed_redirect_protocols=$curl_allowed_protocols # Each server gets its own data dir and daemon instance, so one machine can # serve several bb servers and a full local bb install keeps ~/.bb to itself. data_dir=${BB_DATA_DIR:-"$HOME/.bb-machines/$server_host"} mkdir -p "$data_dir" mkdir -p "$data_dir/logs" +chmod 700 "$data_dir" "$data_dir/logs" canonical_data_dir=$(node -e ' const fs = require("node:fs"); process.stdout.write(fs.realpathSync(process.argv[1])); @@ -190,6 +226,7 @@ bb_app_native_modules="better-sqlite3,node-pty,@parcel/watcher" bb_app_allow_scripts="--allow-scripts=$bb_app_native_modules" port_registry_dir="$HOME/.bb-machines/host-daemon-ports" mkdir -p "$port_registry_dir" +chmod 700 "$port_registry_dir" valid_port() { node -e ' @@ -369,9 +406,9 @@ if valid_port "$previous_host_daemon_port" && [ "$previous_host_daemon_port" != fi complete_step "Using local host-daemon port $host_daemon_port" -# The server's own build is always installed when it offers one: version -# strings cannot distinguish unpublished builds, so an existing bb-app is -# trusted only when the server provides no package (404) or is unreachable. +# Enrollment installs only the package served by this server. There is no PATH +# or npm-registry fallback: silently changing the publisher would defeat the +# operator's explicit choice of server. package_url="${server_url%/}/install/bb-app.tgz" package_dir=$(mktemp -d "${TMPDIR:-/tmp}/bb-app.XXXXXX") package_file="$package_dir/bb-app.tgz" @@ -383,6 +420,8 @@ if [ ! -t 2 ]; then fi active_step "Downloading the server's bb-app package (timeout: 5 minutes)" package_status=$(curl "$curl_output_mode" --show-error --location \ + --proto "$curl_allowed_protocols" \ + --proto-redir "$curl_allowed_redirect_protocols" \ --connect-timeout "$CURL_CONNECT_TIMEOUT_SECONDS" \ --max-time "$PACKAGE_DOWNLOAD_TIMEOUT_SECONDS" \ --output "$package_file" \ @@ -402,27 +441,9 @@ if [ "$package_status" -ge 200 ] && [ "$package_status" -lt 300 ]; then fi bb_app_npm_prefix=$machine_npm_prefix complete_step "Installed the server's bb-app build" -elif command -v bb-app >/dev/null 2>&1; then - bb_app=$(command -v bb-app) - if [ "$package_status" = 404 ]; then - warning_step "The server does not provide its bb-app package; using bb-app at $bb_app" - else - warning_step "Could not download the server's bb-app package (HTTP $package_status); using bb-app at $bb_app" - fi -elif [ "$package_status" = 404 ]; then - require_npm - warning_step "The server does not provide its bb-app package" - active_step "Installing bb-app from the npm registry" - if ! npm install -g "$bb_app_allow_scripts" --prefix "$machine_npm_prefix" bb-app; then - rm -rf "$package_dir" - fail_step "Could not install bb-app for this machine. Check the npm error above, then rerun this command." - exit 1 - fi - bb_app_npm_prefix=$machine_npm_prefix - complete_step "Installed bb-app from the npm registry" else rm -rf "$package_dir" - fail_step "Could not download the server's bb-app package from $package_url (HTTP $package_status)." + fail_step "Could not download the server's bb-app package from $package_url (HTTP $package_status); no PATH or npm fallback is permitted." exit 1 fi rm -rf "$package_dir" @@ -464,6 +485,8 @@ if [ -n "$machine_code" ]; then } active_step "Authorizing this machine with bb connect" redeem_response=$(curl -fsS \ + --proto "$curl_allowed_protocols" \ + --proto-redir "$curl_allowed_redirect_protocols" \ --connect-timeout "$CURL_CONNECT_TIMEOUT_SECONDS" \ --max-time "$MACHINE_CODE_REDEEM_TIMEOUT_SECONDS" \ -X POST \ @@ -534,6 +557,15 @@ if [ -f "$data_dir/auth.json" ]; then fi fi +auto_update_arg= +launchd_auto_update_arg= +systemd_auto_update_arg= +if [ "$auto_update" = yes ]; then + auto_update_arg=--auto-update + launchd_auto_update_arg=' --auto-update' + systemd_auto_update_arg=' --auto-update' +fi + join_pid= if [ "$already_joined" = no ]; then join_log="$data_dir/install-join.log" @@ -541,7 +573,7 @@ if [ "$already_joined" = no ]; then detail "Join progress is logged to $join_log" # The daemon passes this prefix back to npm during protocol self-updates. BB_APP_NPM_PREFIX="$bb_app_npm_prefix" BB_DATA_DIR="$data_dir" nohup "$bb_app" host-daemon join \ - --auto-update \ + $auto_update_arg \ --host-daemon-port "$host_daemon_port" \ --join-code "$join_code" \ --host-id "$host_id" \ @@ -631,7 +663,7 @@ if [ "$platform" = darwin ]; then $escaped_node_bin $escaped_bb_app host-daemon - --auto-update +$launchd_auto_update_arg --host-daemon-port $host_daemon_port --server-url @@ -693,7 +725,7 @@ After=network-online.target Wants=network-online.target [Service] -ExecStart="$escaped_node_bin" "$escaped_bb_app" host-daemon --auto-update --host-daemon-port "$host_daemon_port" --server-url "$escaped_server" +ExecStart="$escaped_node_bin" "$escaped_bb_app" host-daemon$systemd_auto_update_arg --host-daemon-port "$host_daemon_port" --server-url "$escaped_server" Environment="BB_APP_NPM_PREFIX=$escaped_bb_app_npm_prefix" Environment="BB_DATA_DIR=$escaped_data_dir" Restart=always diff --git a/apps/server/src/browser-request-guard.ts b/apps/server/src/browser-request-guard.ts index cc81b097b7..7165a985a2 100644 --- a/apps/server/src/browser-request-guard.ts +++ b/apps/server/src/browser-request-guard.ts @@ -19,7 +19,6 @@ interface BrowserRequestGuardOptions { interface BrowserRequestContext { req: { - url: string; method: string; header(name: string): string | undefined; }; @@ -38,67 +37,7 @@ export function allowedAppOrigins(deps: BrowserRequestGuardDeps): Set { return new Set(buildLocalAppOrigins(args)); } -function knownAppPorts(deps: BrowserRequestGuardDeps): Set { - const ports = new Set([deps.config.serverPort]); - if (deps.config.devAppPort !== undefined) { - ports.add(deps.config.devAppPort); - } - return ports; -} - -function effectivePort(url: URL): number | null { - if (url.port.length > 0) { - const port = Number(url.port); - return Number.isInteger(port) ? port : null; - } - if (url.protocol === "http:") { - return 80; - } - if (url.protocol === "https:") { - return 443; - } - return null; -} - -function parseRequestHost(host: string, protocol: string): URL | null { - try { - const url = new URL(`${protocol}//${host}`); - return url.username.length === 0 && - url.password.length === 0 && - url.pathname === "/" && - url.search.length === 0 && - url.hash.length === 0 - ? url - : null; - } catch { - return null; - } -} - -function requestTargets(context: BrowserRequestContext): URL[] { - const requestUrl = new URL(context.req.url); - const targets = [requestUrl]; - const forwardedProtocol = - context.req.header("x-forwarded-proto")?.split(",", 1)[0]?.trim() || - requestUrl.protocol.replace(/:$/u, ""); - - for (const rawHost of [ - context.req.header("host"), - context.req.header("x-forwarded-host")?.split(",", 1)[0]?.trim(), - ]) { - if (rawHost === undefined || rawHost.length === 0) { - continue; - } - const target = parseRequestHost(rawHost, `${forwardedProtocol}:`); - if (target !== null) { - targets.push(target); - } - } - return targets; -} - function isTrustedOrigin( - context: BrowserRequestContext, deps: BrowserRequestGuardDeps, origin: string, ): boolean { @@ -115,21 +54,7 @@ function isTrustedOrigin( return false; } - if (allowedAppOrigins(deps).has(originUrl.origin)) { - return true; - } - - const targets = requestTargets(context); - if (targets.some((target) => target.origin === originUrl.origin)) { - return true; - } - - const originPort = effectivePort(originUrl); - if (originPort === null || !knownAppPorts(deps).has(originPort)) { - return false; - } - - return targets.some((target) => target.hostname === originUrl.hostname); + return allowedAppOrigins(deps).has(originUrl.origin); } function isJsonContentType(contentType: string | undefined): boolean { @@ -141,8 +66,8 @@ function isJsonContentType(contentType: string | undefined): boolean { /** * Guards privileged local-browser boundaries without imposing credentials on * non-browser clients. Browsers send Origin; Node SDK, CLI, and server-to-server - * callers commonly do not. Dynamic LAN/dev origins must share the request host - * and use a configured BB port, while configured app origins match exactly. + * callers commonly do not. Only configured app origins are trusted; request + * Host and X-Forwarded-Host headers never expand that allowlist. */ export function browserRequestProblem( context: BrowserRequestContext, @@ -150,7 +75,7 @@ export function browserRequestProblem( options: BrowserRequestGuardOptions = {}, ): BrowserRequestProblem | null { const origin = context.req.header("origin"); - if (origin !== undefined && !isTrustedOrigin(context, deps, origin)) { + if (origin !== undefined && !isTrustedOrigin(deps, origin)) { return { status: 403, error: `origin "${origin}" is not a local BB app origin`, diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index cf6054172a..883165be11 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; import { loadServerConfig } from "@bb/config/server"; +import { ensurePrivateDirectory } from "@bb/logger"; import { installSafeProcessDiagnostics, writeSafeProcessDiagnosticReport, @@ -7,6 +8,7 @@ import { const serverConfig = loadServerConfig(); const diagnosticsLogsDir = join(serverConfig.BB_DATA_DIR, "logs"); +ensurePrivateDirectory(diagnosticsLogsDir); installSafeProcessDiagnostics({ logsDir: diagnosticsLogsDir, diff --git a/apps/server/src/routes/plugins.ts b/apps/server/src/routes/plugins.ts index 4c563d7bcb..b4e0e8c1ac 100644 --- a/apps/server/src/routes/plugins.ts +++ b/apps/server/src/routes/plugins.ts @@ -120,9 +120,8 @@ function parsePluginMentionTrigger( * "local" auth (design §4.6): the request must come from the BB app itself. * The load-bearing CSRF defense is the JSON-only rule below — a cross-origin * JSON POST always triggers a CORS preflight, which the server's allowlist - * denies. The shared Origin check also tolerates BB being served over - * LAN/Tailscale addresses the server cannot enumerate, but only when the - * origin hostname is bound to the request hostname. + * denies. The shared Origin check accepts only configured BB app origins; + * request Host and X-Forwarded-Host headers never expand that allowlist. */ function localAuthProblem( context: Context, @@ -634,7 +633,7 @@ export function registerPluginRoutes( }); // bb.rpc dispatcher (design §4.6): always "local" auth semantics — - // JSON-only body plus the Origin/Host check. + // JSON-only body plus the configured-origin check. app.post("/plugins/:id/rpc/:method", async (context) => { const id = context.req.param("id"); const method = context.req.param("method"); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index dd197e1474..3b6922bef7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -486,13 +486,8 @@ export function createApp( app.use( "*", cors({ - origin: (origin, context) => { - const allowedCorsOrigins = allowedAppOrigins(deps); - const requestOrigin = new URL(context.req.url).origin; - if (origin === requestOrigin || allowedCorsOrigins.has(origin)) { - return origin; - } - return null; + origin: (origin) => { + return allowedAppOrigins(deps).has(origin) ? origin : null; }, }), ); @@ -646,10 +641,11 @@ export function createApp( // origin could drive this API blind. Reject a foreign browser origin here // instead. `requireJsonForMutation` is deliberately NOT set: it answers 415 // to any mutation without `application/json`, which would break every - // existing `curl -d` caller. The origin check alone stops browser CSRF, - // because a browser always sends `Origin` on a cross-origin mutation. - // Non-browser callers (curl, the `bb` CLI, the SDK) send no `Origin` and pass - // through untouched. + // existing `curl -d` caller. The origin check is a browser CSRF boundary, + // not authentication. It stops cross-origin browser requests because + // browsers send `Origin` on a cross-origin mutation. Non-browser callers + // (curl, the `bb` CLI, the SDK) send no `Origin` and pass through untouched; + // the server itself must remain loopback-bound. publicApi.use("*", async (context, next) => { // A plugin's own HTTP routes declare their auth mode (`local` | `token` | // `none`). `none` is deliberately reachable from any origin, and `token` diff --git a/apps/server/src/services/plugins/install-sources.ts b/apps/server/src/services/plugins/install-sources.ts index 5ea1358e1a..2cd1d44571 100644 --- a/apps/server/src/services/plugins/install-sources.ts +++ b/apps/server/src/services/plugins/install-sources.ts @@ -19,6 +19,7 @@ import { omitNpmScriptPolicyEnv, spawnPortableOutputProcess, } from "@bb/process-utils"; +import { redactSensitiveText } from "@bb/logger"; /** * What a `git:` spec asks for. @@ -750,7 +751,7 @@ export async function runInstallCommand( resolve(); return; } - const tail = stderr.trim().slice(-1000); + const tail = redactSensitiveText(stderr.trim()).slice(-1000); reject( new Error( `${command} ${args[0]} failed (exit ${code ?? "signal"})${tail ? `: ${tail}` : ""}`, diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts index 55960be603..5c6f40f4a9 100644 --- a/apps/server/src/services/plugins/plugin-api.ts +++ b/apps/server/src/services/plugins/plugin-api.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from "node:fs"; +import { chmodSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import Database from "better-sqlite3"; import { CronExpressionParser } from "cron-parser"; @@ -706,7 +706,8 @@ export function createPluginApi(options: { if (index !== -1) databaseHandles.splice(index, 1); } const dir = join(dataDir, "plugins", pluginId); - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { mode: 0o700, recursive: true }); + chmodSync(dir, 0o700); const database = new Database(join(dir, "data.db")); database.pragma("journal_mode = WAL"); database.pragma("busy_timeout = 5000"); diff --git a/apps/server/src/services/plugins/plugin-log.ts b/apps/server/src/services/plugins/plugin-log.ts index 06afbf4a51..f0bff4bd53 100644 --- a/apps/server/src/services/plugins/plugin-log.ts +++ b/apps/server/src/services/plugins/plugin-log.ts @@ -1,6 +1,13 @@ -import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs"; +import { + appendFileSync, + chmodSync, + mkdirSync, + renameSync, + statSync, +} from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { redactSensitiveText } from "@bb/logger"; /** * Per-plugin log file (design §3 observability): every `bb.log` line is @@ -31,17 +38,28 @@ export function appendPluginLogLine( ): void { try { const dir = pluginLogsDir(dataDir, pluginId); - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { mode: 0o700, recursive: true }); + chmodSync(dir, 0o700); const file = join(dir, PLUGIN_LOG_FILE); try { if (statSync(file).size > PLUGIN_LOG_MAX_BYTES) { - renameSync(file, join(dir, PLUGIN_LOG_ROTATED_FILE)); + const rotated = join(dir, PLUGIN_LOG_ROTATED_FILE); + renameSync(file, rotated); + chmodSync(rotated, 0o600); } } catch { // Missing file: nothing to rotate. } - const line = JSON.stringify({ ts: Date.now(), level, message }); - appendFileSync(file, `${line}\n`, "utf8"); + const line = JSON.stringify({ + ts: Date.now(), + level, + message: redactSensitiveText(message), + }); + appendFileSync(file, `${line}\n`, { + encoding: "utf8", + mode: 0o600, + }); + chmodSync(file, 0o600); } catch { // Best effort only. } @@ -69,5 +87,5 @@ export async function readPluginLogTail( // Missing file: nothing logged there yet. } } - return tail <= 0 ? [] : lines.slice(-tail); + return tail <= 0 ? [] : lines.slice(-tail).map(redactSensitiveText); } diff --git a/apps/server/src/services/plugins/plugin-runtime.ts b/apps/server/src/services/plugins/plugin-runtime.ts index 781a06d50c..3a7d0557bb 100644 --- a/apps/server/src/services/plugins/plugin-runtime.ts +++ b/apps/server/src/services/plugins/plugin-runtime.ts @@ -35,6 +35,7 @@ import { import { PluginHostArtifactRegistry } from "./plugin-host-artifact-registry.js"; import { getPluginBuildToolchain } from "./build-toolchain.js"; import { createNodeBbSdk, type BbSdk } from "@bb/sdk"; +import { redactSensitiveText } from "@bb/logger"; import { experimental_aiServicesHostContract } from "@get-bb/plugin-sdk/ai-services"; import { getInstalledPlugin, @@ -493,13 +494,15 @@ export function createPluginRuntime(context: PluginRuntimeContext) { status: PluginRuntimeStatus, detail: string | null = null, ): void { - baseStatuses.set(id, { status, detail }); + const safeDetail = detail === null ? null : redactSensitiveText(detail); + baseStatuses.set(id, { status, detail: safeDetail }); const buildProblems = devBuildProblems.get(id); publishStatus( id, status, - [detail, buildProblems?.frontend, buildProblems?.host] + [safeDetail, buildProblems?.frontend, buildProblems?.host] .filter((part): part is string => part !== null && part !== undefined) + .map(redactSensitiveText) .join("; ") || null, ); } @@ -514,7 +517,8 @@ export function createPluginRuntime(context: PluginRuntimeContext) { if (problems[kind] === undefined) return; delete problems[kind]; } else { - problems[kind] = `${DEV_BUILD_PROBLEM_LABELS[kind]}: ${message}`; + problems[kind] = + `${DEV_BUILD_PROBLEM_LABELS[kind]}: ${redactSensitiveText(message)}`; } if (Object.keys(problems).length === 0) devBuildProblems.delete(id); else devBuildProblems.set(id, problems); @@ -1603,13 +1607,13 @@ export function createPluginRuntime(context: PluginRuntimeContext) { ...declaration, pluginId: row.id, completeInference: async (input, options) => - experimental_aiServicesHostContract["ai.inference.complete"].output.parse( - await call("ai.inference.complete", input, options), - ), + experimental_aiServicesHostContract[ + "ai.inference.complete" + ].output.parse(await call("ai.inference.complete", input, options)), transcribeVoice: async (input, options) => - experimental_aiServicesHostContract["ai.voice.transcribe"].output.parse( - await call("ai.voice.transcribe", input, options), - ), + experimental_aiServicesHostContract[ + "ai.voice.transcribe" + ].output.parse(await call("ai.voice.transcribe", input, options)), }); }, registerProvider: (declaration) => { diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index 3156ca3192..d90bce712d 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -48,6 +48,7 @@ import { pluginPublisherLabel, } from "../plugin-catalog/marketplace-publishers.js"; import { deleteSecretFile, readOrCreateSecretFile } from "@bb/secret-storage"; +import { redactSensitiveText } from "@bb/logger"; import { ROOT_PLUGIN_SOURCE_SELECTION, type PluginCapabilitySummary, @@ -2143,7 +2144,7 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { async runCliCommand(id, argv, ctx) { const fail = (stderr: string) => enforcePluginCliOutputLimit( - { exitCode: 1, stdout: "", stderr }, + { exitCode: 1, stdout: "", stderr: redactSensitiveText(stderr) }, argv.includes("--json"), ); const plugin = loaded.get(id); @@ -2175,7 +2176,10 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { { exitCode: result.exitCode, stdout: typeof result.stdout === "string" ? result.stdout : "", - stderr: typeof result.stderr === "string" ? result.stderr : "", + stderr: + typeof result.stderr === "string" + ? redactSensitiveText(result.stderr) + : "", }, argv.includes("--json"), ); diff --git a/apps/server/src/services/projects/attachments.ts b/apps/server/src/services/projects/attachments.ts index 1ed4b20eff..c800638a2c 100644 --- a/apps/server/src/services/projects/attachments.ts +++ b/apps/server/src/services/projects/attachments.ts @@ -1,7 +1,7 @@ // For now, we store attachments on the server's local file system. // We might move this to something like R2 or S3 in the future. // oxlint-disable-next-line no-restricted-imports -import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, @@ -180,12 +180,14 @@ export async function storeAttachment( } const dir = projectAttachmentDir(dataDir, projectId); - await mkdir(dir, { recursive: true }); + await mkdir(dir, { mode: 0o700, recursive: true }); + await chmod(dir, 0o700); const storedName = buildStoredFilename(file.name); const outputPath = join(dir, storedName); const bytes = Buffer.from(await file.arrayBuffer()); - await writeFile(outputPath, bytes); + await writeFile(outputPath, bytes, { mode: 0o600 }); + await chmod(outputPath, 0o600); return { type: isImage ? "localImage" : "localFile", @@ -249,8 +251,11 @@ export async function copyProjectAttachments( await Promise.all( attachments.map(async ({ content, targetPath }) => { - await mkdir(dirname(targetPath), { recursive: true }); - await writeFile(targetPath, content); + const targetDirectory = dirname(targetPath); + await mkdir(targetDirectory, { mode: 0o700, recursive: true }); + await chmod(targetDirectory, 0o700); + await writeFile(targetPath, content, { mode: 0o600 }); + await chmod(targetPath, 0o600); }), ); } diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 484f0a7b80..3a29a30d27 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -22,14 +22,12 @@ message agents, or inspect projects, providers, and environments. targets. The Add machine installer injects its enrolled daemon's selected local API port automatically and atomically reserves it across default and custom machine data directories. -- The main server and source Vite app bind to loopback by default. Use bb - connect or a private Tailscale Serve URL for remote browsers and execution - machines. `--server-bind-host 0.0.0.0` is a compatibility escape hatch only: - the public API is unauthenticated and permits command execution and file - reads, so wildcard binding requires a trusted network boundary. The startup - listener and `app` rows then show `http://0.0.0.0:`; health checks and - the colocated daemon still use loopback. This opt-in is IPv4-only. Containers - must also publish the port to the host. +- The main server and source Vite app bind to loopback. Use bb connect or a + private Tailscale Serve URL for remote browsers and execution machines. The + public API is unauthenticated and permits command execution and file reads, + so off-loopback `BB_SERVER_BIND_HOST` values are refused. The Origin check is + a browser CSRF boundary, not authentication. Containers must publish a + loopback listener only through a private access boundary. ## Environment Setup Script @@ -73,9 +71,9 @@ message agents, or inspect projects, providers, and environments. roots, and `BB_FF_*` flags. `BB_LOG_LEVEL` is also startup-only. Use `bb-app config`, not `bb-app env`, to change `BB_APP_URL`, `BB_INFERENCE`, `BB_INFERENCE_FALLBACK`, or `BB_TRANSCRIPTION` live. After a startup-only - change, run `bb-app stop && bb-app start` or restart the desktop app. Until - then, a server previously bound to `0.0.0.0` remains exposed even if - `BB_SERVER_BIND_HOST` was changed or unset. + change, run `bb-app stop && bb-app start` or restart the desktop app. A server + from an older release that was bound off-loopback remains exposed until it is + restarted. - Settings → General holds server-backed app-wide preferences. For details, read `references/app-settings.md` (in this skill's directory). - Keep Awake is a standalone builtin plugin. Use `bb keep-awake enable` and @@ -248,15 +246,23 @@ connect` restores the command. Plugins → Connect shows the current URL, QR - 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 - gate; revoke a lost machine from the getbb.app dashboard. The installer uses - the server's exact `/install/bb-app.tgz` artifact and uses the npm registry - only on a 404. It installs under the enrollment's bb data directory, without - `sudo` or a global npm configuration, and enables daemon `--auto-update`. - Newer protocol mismatches update that private install with a persisted - exponential retry backoff from 5 seconds to 5 minutes, then let - launchd/systemd restart the daemon. Auto-update never downgrades. To bypass a - transient backoff, use `bb machine retry-update `. Remove - `--auto-update` from the service definition and reload it to opt out. + gate; revoke a lost machine from the getbb.app dashboard. The installer + requires HTTPS for non-loopback server URLs (HTTP is allowed only for explicit + loopback local development), installs only the server's exact + `/install/bb-app.tgz` artifact, and fails if it is unavailable. It never + reuses PATH or falls back to the npm registry. It installs under the + enrollment's bb data directory, without `sudo` or a global npm configuration. + Installed services leave daemon auto-update off by default; pass + `--auto-update` to the installer only after explicitly trusting that server's + unsigned package for unattended updates. Newer protocol mismatches then update + that private install with a persisted exponential retry backoff from 5 seconds + to 5 minutes, then let launchd/systemd restart the daemon. HTTPS protects + transport but does not provide publisher signing or digest binding, so keep + this opt-in disabled until that distribution contract exists unless the server + and build pipeline are trusted. Auto-update never downgrades. To bypass a + transient backoff, use `bb machine retry-update `. Existing service + files containing `--auto-update` must have that flag removed manually and the + service reloaded to opt out. - Run `bb machine list` to see machine names, IDs, connection status, and last seen time (`--json` returns the raw host list). Use `--machine ` (alias `--host`) on `bb thread spawn` to run in a personal or unmanaged diff --git a/apps/server/src/start-server.ts b/apps/server/src/start-server.ts index 2085d021b7..1f65ecf6a2 100644 --- a/apps/server/src/start-server.ts +++ b/apps/server/src/start-server.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import type { ServerConfig } from "@bb/config/server"; import { isLoopbackHostname } from "@bb/config/loopback"; import { toOptionalString } from "@bb/config/strings"; -import { createLogger } from "@bb/logger"; +import { createLogger, ensurePrivateDirectory } from "@bb/logger"; import { getAppSettings } from "@bb/db"; import { initDb } from "./db.js"; import { createApp } from "./server.js"; @@ -36,10 +36,19 @@ import { HostSharedPortCoordinator } from "./ws/host-shared-ports.js"; interface StartHttpListenerArgs { fetch: Parameters[0]["fetch"]; - serverConfig: Pick; + serverConfig: { + BB_SERVER_BIND_HOST: string; + BB_SERVER_PORT: number; + }; } export function startHttpListener(args: StartHttpListenerArgs) { + if (!isLoopbackHostname(args.serverConfig.BB_SERVER_BIND_HOST)) { + throw new Error( + "Refusing to start the unauthenticated server on a non-loopback address", + ); + } + return serve({ hostname: args.serverConfig.BB_SERVER_BIND_HOST, port: args.serverConfig.BB_SERVER_PORT, @@ -48,6 +57,7 @@ export function startHttpListener(args: StartHttpListenerArgs) { } export async function runServer(serverConfig: ServerConfig): Promise { + ensurePrivateDirectory(serverConfig.BB_DATA_DIR); const logger = createLogger({ component: "server", dataDir: serverConfig.BB_DATA_DIR, @@ -216,13 +226,6 @@ export async function runServer(serverConfig: ServerConfig): Promise { logger.error({ err: error }, "Startup recovery sweep failed"); }); - if (!isLoopbackHostname(serverConfig.BB_SERVER_BIND_HOST)) { - logger.warn( - { bindHost: serverConfig.BB_SERVER_BIND_HOST }, - "SECURITY WARNING: The public API is unauthenticated and permits command execution and file reads. Wildcard server binding must only be used behind a trusted network boundary.", - ); - } - const server = startHttpListener({ fetch: app.fetch, serverConfig, diff --git a/apps/server/test/app/install-machine-script.test.ts b/apps/server/test/app/install-machine-script.test.ts index dedab1f227..2b34b7d693 100644 --- a/apps/server/test/app/install-machine-script.test.ts +++ b/apps/server/test/app/install-machine-script.test.ts @@ -7,6 +7,7 @@ import { readFileSync, realpathSync, rmSync, + statSync, symlinkSync, writeFileSync, } from "node:fs"; @@ -169,6 +170,7 @@ process.on("SIGTERM", () => server.close(() => process.exit(0))); function writeServerInstallTools( fixture: ReturnType, artifactStatus: 200 | 404, + bbAppScript = createEnrollingBbAppScript({ hostId: "host-test" }), ): void { const curlLog = join(fixture.dataDir, "curl.log"); const npmLog = join(fixture.dataDir, "npm.log"); @@ -190,10 +192,7 @@ esac `, ); const bbAppTemplatePath = join(fixture.dataDir, "bb-app-template"); - writeExecutable( - bbAppTemplatePath, - createEnrollingBbAppScript({ hostId: "host-test" }), - ); + writeExecutable(bbAppTemplatePath, bbAppScript); writeExecutable( join(fixture.binDir, "npm"), `#!/bin/sh @@ -216,35 +215,6 @@ done ); } -function writeEnrollingBbApp( - fixture: ReturnType, - invocationPath: string, - hostId = "host-test", - statusServerUrl?: string, -): void { - writeExecutable( - join(fixture.binDir, "bb-app"), - createEnrollingBbAppScript({ hostId, invocationPath, statusServerUrl }), - ); -} - -function writeCurlArtifactMock( - fixture: ReturnType, - artifactStatus: number, -): void { - writeExecutable( - join(fixture.binDir, "curl"), - `#!/bin/sh -output= -while [ "$#" -gt 0 ]; do - if [ "$1" = --output ]; then output=$2; shift 2; else shift; fi -done -[ -z "$output" ] || printf '%s' 'fixture-tarball' >"$output" -printf '%s' '${artifactStatus}' -`, - ); -} - afterEach(() => { for (const directory of createdDirectories.splice(0)) { try { @@ -302,11 +272,38 @@ describe("machine install script", () => { expect(result.stderr).not.toContain("TypeError"); }); - it("uses bb-app from PATH and passes the launcher join flags verbatim", () => { + it("rejects non-loopback HTTP before downloading an install artifact", () => { + const fixture = createFixture(); + const result = runScript( + [ + "--join-code", + "join-secret", + "--host-id", + "host-test", + "--server", + "http://machine.getbb.app", + ], + fixture, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Server URL must use HTTPS; HTTP is allowed only for loopback local development.", + ); + expect(existsSync(join(fixture.dataDir, "curl.log"))).toBe(false); + }); + + it("installs the server package and leaves auto-update disabled by default", () => { const fixture = createFixture(); const invocationPath = join(fixture.dataDir, "invocation"); - writeCurlArtifactMock(fixture, 404); - writeEnrollingBbApp(fixture, invocationPath); + writeServerInstallTools( + fixture, + 200, + createEnrollingBbAppScript({ + hostId: "host-test", + invocationPath, + }), + ); const result = runScript(JOIN_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); @@ -319,7 +316,6 @@ describe("machine install script", () => { expect(readFileSync(invocationPath, "utf8").trim().split("\n")).toEqual([ "host-daemon", "join", - "--auto-update", "--host-daemon-port", selectedPort, "--join-code", @@ -335,16 +331,48 @@ describe("machine install script", () => { process.kill(daemonPid, "SIGTERM"); }); - it("accepts the daemon's normalized loopback server URL", () => { + it("passes auto-update only when the installer explicitly opts in", () => { const fixture = createFixture(); const invocationPath = join(fixture.dataDir, "invocation"); - writeCurlArtifactMock(fixture, 404); - writeEnrollingBbApp( + writeServerInstallTools( fixture, - invocationPath, + 200, + createEnrollingBbAppScript({ + hostId: "host-test", + invocationPath, + }), + ); + const result = runScript([...JOIN_ARGS, "--auto-update"], fixture, { + BB_INSTALL_SKIP_SERVICE: "1", + }); + + expect(result.status, result.stderr).toBe(0); + const selectedPort = readFileSync( + join(fixture.dataDir, "host-daemon-port"), + "utf8", + ).trim(); + 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", - "http://127.0.0.1:20101", + "--server-url", + "https://machine.getbb.app", + ]); + const daemonPid = Number( + readFileSync(join(fixture.dataDir, "install-daemon.pid"), "utf8"), ); + process.kill(daemonPid, "SIGTERM"); + }); + + it("accepts the daemon's normalized loopback server URL", () => { + const fixture = createFixture(); + writeServerInstallTools(fixture, 200); const result = runScript( [ "--join-code", @@ -375,6 +403,8 @@ describe("machine install script", () => { }); expect(result.status, result.stderr).toBe(0); + expect(statSync(fixture.dataDir).mode & 0o777).toBe(0o700); + expect(statSync(join(fixture.dataDir, "logs")).mode & 0o777).toBe(0o700); const npmInvocation = readFileSync( join(fixture.dataDir, "npm.log"), "utf8", @@ -388,7 +418,7 @@ describe("machine install script", () => { process.kill(daemonPid, "SIGTERM"); }); - it("prefers the server-matched tarball when bb-app is absent", () => { + it("installs the server-matched tarball when bb-app is absent", () => { const fixture = createFixture(); writeServerInstallTools(fixture, 200); const result = runScript(JOIN_ARGS, fixture, { @@ -405,7 +435,7 @@ describe("machine install script", () => { ); expect(npmInvocation).not.toContain("bb-app\n"); expect(readFileSync(join(fixture.dataDir, "curl.log"), "utf8")).toContain( - "--silent --show-error --location --connect-timeout 10 --max-time 300", + "--silent --show-error --location --proto =https --proto-redir =https --connect-timeout 10 --max-time 300", ); expect(result.stdout).toContain( "Setting up this machine as host-test for https://machine.getbb.app", @@ -434,21 +464,18 @@ describe("machine install script", () => { process.kill(daemonPid, "SIGTERM"); }); - it("falls back to npm only when the server artifact returns 404", () => { + it("fails without a PATH or npm fallback when the server artifact is unavailable", () => { const fixture = createFixture(); writeServerInstallTools(fixture, 404); + writeExecutable(join(fixture.binDir, "bb-app"), "#!/bin/sh\nexit 99\n"); const result = runScript(JOIN_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", }); - expect(result.status, result.stderr).toBe(0); - expect(readFileSync(join(fixture.dataDir, "npm.log"), "utf8")).toMatch( - /^install -g --allow-scripts=better-sqlite3,node-pty,@parcel\/watcher --prefix \/.*\/data\/npm bb-app\n$/u, - ); - const daemonPid = Number( - readFileSync(join(fixture.dataDir, "install-daemon.pid"), "utf8"), - ); - process.kill(daemonPid, "SIGTERM"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("no PATH or npm fallback is permitted"); + expect(existsSync(join(fixture.dataDir, "npm.log"))).toBe(false); + expect(existsSync(join(fixture.dataDir, "install-daemon.pid"))).toBe(false); }); it("fails loudly when npm skipped the native add-on install scripts", () => { @@ -494,8 +521,7 @@ describe("machine install script", () => { it("refuses a data dir enrolled for a different host instead of faking success", () => { const fixture = createFixture(); - writeCurlArtifactMock(fixture, 404); - writeExecutable(join(fixture.binDir, "bb-app"), "#!/bin/sh\nexit 99\n"); + writeServerInstallTools(fixture, 200); writeJoinedState(fixture, "https://machine.getbb.app", "host-other"); const result = runScript(JOIN_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1", @@ -528,8 +554,11 @@ describe("machine install script", () => { }); const fixture = createFixture(); const invocationPath = join(fixture.dataDir, "invocation"); - writeCurlArtifactMock(fixture, 404); - writeEnrollingBbApp(fixture, invocationPath); + writeServerInstallTools( + fixture, + 200, + createEnrollingBbAppScript({ hostId: "host-test", invocationPath }), + ); try { const result = runScript(JOIN_ARGS, fixture, { @@ -560,6 +589,7 @@ describe("machine install script", () => { it("atomically reserves different ports for concurrent custom data directories", async () => { const fixture = createFixture(); + writeServerInstallTools(fixture, 200); const firstDataDir = join(fixture.homeDir, "custom-machine-one"); const secondDataDir = join(fixture.homeDir, "custom-machine-two"); mkdirSync(firstDataDir, { recursive: true }); @@ -568,8 +598,6 @@ describe("machine install script", () => { const secondFixture = { ...fixture, dataDir: secondDataDir }; writeJoinedState(firstFixture); writeJoinedState(secondFixture); - writeCurlArtifactMock(fixture, 404); - writeExecutable(join(fixture.binDir, "bb-app"), "#!/bin/sh\nexit 99\n"); const [firstResult, secondResult] = await Promise.all([ runScriptAsync(JOIN_ARGS, firstFixture, { BB_INSTALL_SKIP_SERVICE: "1" }), @@ -603,8 +631,11 @@ describe("machine install script", () => { 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); + writeServerInstallTools( + fixture, + 200, + createEnrollingBbAppScript({ hostId: "host-test", invocationPath }), + ); const result = runScript( [ "--join-code", @@ -622,7 +653,7 @@ describe("machine install script", () => { expect(result.status, result.stderr).toBe(0); expect(readFileSync(join(fixture.dataDir, "curl.log"), "utf8")).toContain( - "--connect-timeout 10 --max-time 30 -X POST", + "--proto =https --proto-redir =https --connect-timeout 10 --max-time 30 -X POST", ); expect(readFileSync(invocationPath, "utf8")).not.toContain("bbcm_durable"); expect(readFileSync(invocationPath, "utf8")).not.toContain( @@ -643,9 +674,9 @@ describe("machine install script", () => { it("reports periodic progress while a host daemon is still joining", () => { const fixture = createFixture(); - writeCurlArtifactMock(fixture, 404); - writeExecutable( - join(fixture.binDir, "bb-app"), + writeServerInstallTools( + fixture, + 200, `#!/usr/bin/env node setInterval(() => {}, 1000); `, @@ -666,7 +697,7 @@ setInterval(() => {}, 1000); expect(result.stderr).toContain("Timed out waiting for host daemon"); }); - it("installs an idempotent macOS launch agent for joined state", () => { + it("installs an idempotent macOS launch agent with explicit auto-update opt-in", () => { const fixture = createFixture(); writeJoinedState(fixture); writeServerInstallTools(fixture, 200); @@ -691,6 +722,7 @@ fi "host-test", "--server", "https://machine.getbb.app", + "--auto-update", ], fixture, ); @@ -784,7 +816,7 @@ fi "utf8", ).trim(); expect(unit).toContain( - `host-daemon --auto-update --host-daemon-port "${selectedPort}" --server-url "https://machine.getbb.app"`, + `host-daemon --host-daemon-port "${selectedPort}" --server-url "https://machine.getbb.app"`, ); expect(unit).toContain( `Environment="BB_APP_NPM_PREFIX=${realpathSync(fixture.dataDir)}/npm"`, diff --git a/apps/server/test/app/startup-diagnostics.test.ts b/apps/server/test/app/startup-diagnostics.test.ts index 7264d4307c..60c800e160 100644 --- a/apps/server/test/app/startup-diagnostics.test.ts +++ b/apps/server/test/app/startup-diagnostics.test.ts @@ -36,23 +36,11 @@ describe("server startup diagnostics", () => { expect(packageJson).toContain("src/start-server.ts dist/start-server.js"); }); - it.each([ - { - bindHost: undefined, - expectedAddress: "127.0.0.1", - name: "binds the default server listener to IPv4 loopback", - }, - { - bindHost: "0.0.0.0", - expectedAddress: "0.0.0.0", - name: "binds the explicit wildcard listener to IPv4 only", - }, - ])("$name", async ({ bindHost, expectedAddress }) => { + it("binds the default server listener to IPv4 loopback", async () => { const serverConfig = loadServerConfig({ env: { BB_DATA_DIR: "/tmp/bb-server-listener-test", BB_HOST_DAEMON_PORT: "49162", - ...(bindHost === undefined ? {} : { BB_SERVER_BIND_HOST: bindHost }), BB_SERVER_PORT: "49161", NODE_ENV: "development", }, @@ -67,7 +55,7 @@ describe("server startup diagnostics", () => { await once(server, "listening"); } expect(server.address()).toMatchObject({ - address: expectedAddress, + address: "127.0.0.1", family: "IPv4", }); } finally { @@ -82,4 +70,16 @@ describe("server startup diagnostics", () => { }); } }); + + it("refuses a non-loopback listener even when config parsing is bypassed", () => { + expect(() => + startHttpListener({ + fetch: () => new Response("ok"), + serverConfig: { + BB_SERVER_BIND_HOST: "0.0.0.0", + BB_SERVER_PORT: 0, + }, + }), + ).toThrow(/non-loopback/u); + }); }); diff --git a/apps/server/test/helpers/provider-registry.ts b/apps/server/test/helpers/provider-registry.ts index 7371ffdb24..598dbba14a 100644 --- a/apps/server/test/helpers/provider-registry.ts +++ b/apps/server/test/helpers/provider-registry.ts @@ -417,6 +417,7 @@ export async function registerFakeProviders( reasoningLevels: ["low", "medium", "high"], }, composerActions: ["plan", "goal"], + env: { passthrough: ["SCRIPTED_ECHO_RECORD_PATH"] }, }), readSettings: NO_PLUGIN_SETTINGS, }), diff --git a/apps/server/test/security/api-origin-guard.test.ts b/apps/server/test/security/api-origin-guard.test.ts index 86ac76707a..f97c63625e 100644 --- a/apps/server/test/security/api-origin-guard.test.ts +++ b/apps/server/test/security/api-origin-guard.test.ts @@ -119,11 +119,20 @@ describe("/api/v1 browser origin guard", () => { ).toBe(403); }); - it("accepts the app's own origin and the request host", async () => { + it("accepts the app's configured loopback origins", async () => { server = await startTestServer(); - const origin = new URL(server.baseUrl).origin; + const base = new URL(server.baseUrl); - expect(await statusFor(server.baseUrl, { headers: { origin } })).toBe(200); + expect( + await statusFor(server.baseUrl, { + headers: { origin: base.origin }, + }), + ).toBe(200); + expect( + await statusFor(server.baseUrl, { + headers: { origin: `http://localhost:${base.port}` }, + }), + ).toBe(200); }); // The bb Connect tunnel forwards a remote request to the loopback server @@ -160,63 +169,41 @@ describe("/api/v1 browser origin guard", () => { ).toBe(403); }); - // bb is commonly served over a LAN address or Tailscale Serve, which the - // server cannot enumerate into an allowlist. `isTrustedOrigin` admits those - // by matching the origin against the request `Host` (or `X-Forwarded-Host` - // with `X-Forwarded-Proto`). `fetch` silently drops a `Host` override, so - // these go over `http.request` — a `fetch`-based version of this test passes - // for the wrong reason. - it("accepts bb served over a LAN address or Tailscale Serve", async () => { + // Request authority is attacker-controlled when a browser can reach a + // loopback listener through a DNS-rebinding name. Neither Host nor + // X-Forwarded-Host may turn an unconfigured origin into a trusted one. + it("rejects hostile origins even when request authority matches", async () => { server = await startTestServer(); const port = new URL(server.baseUrl).port; - // LAN: the reverse proxy passes Host through. expect( await rawStatus(server.baseUrl, { origin: `http://192.168.1.5:${port}`, host: `192.168.1.5:${port}`, }), - ).toBe(200); + ).toBe(403); - // Tailscale Serve: TLS terminated upstream, Host preserved. bb supports - // this shape deliberately (see the plugin-wire suite), so it must pass. expect( await rawStatus(server.baseUrl, { origin: "https://box.ts.net", host: "box.ts.net", "x-forwarded-proto": "https", }), - ).toBe(200); + ).toBe(403); - // A LAN deployment behind a proxy that rewrites Host but forwards it. expect( await rawStatus(server.baseUrl, { origin: `http://192.168.1.5:${port}`, host: `127.0.0.1:${port}`, "x-forwarded-host": `192.168.1.5:${port}`, }), - ).toBe(200); + ).toBe(403); - // IPv6 literals are addresses too. expect( await rawStatus(server.baseUrl, { origin: `http://[::1]:${port}`, host: `[::1]:${port}`, }), - ).toBe(200); - }); - - // A rewriting proxy must forward the original authority. bb already imposes - // this on its plugin routes, which have used the same check all along. - it("requires a rewriting proxy to send X-Forwarded-Host", async () => { - server = await startTestServer(); - const port = new URL(server.baseUrl).port; - - expect( - await rawStatus(server.baseUrl, { - origin: `http://192.168.1.5:${port}`, - host: `127.0.0.1:${port}`, - }), ).toBe(403); }); diff --git a/apps/server/test/services/plugins/plugin-log.test.ts b/apps/server/test/services/plugins/plugin-log.test.ts new file mode 100644 index 0000000000..f24648a187 --- /dev/null +++ b/apps/server/test/services/plugins/plugin-log.test.ts @@ -0,0 +1,43 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + appendPluginLogLine, + readPluginLogTail, +} from "../../../src/services/plugins/plugin-log.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((directory) => fs.rm(directory, { force: true, recursive: true })), + ); +}); + +describe("plugin logs", () => { + it("redacts credentials before persisting and uses owner-only permissions", async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "bb-plugin-log-")); + tempDirs.push(dataDir); + const credential = "sk-example-plugin-log-secret"; + + appendPluginLogLine( + dataDir, + "example", + "error", + `provider failed with OPENAI_API_KEY=${credential}`, + ); + + const logDir = path.join(dataDir, "plugins", "example", "logs"); + expect((await fs.stat(logDir)).mode & 0o777).toBe(0o700); + expect((await fs.stat(path.join(logDir, "plugin.log"))).mode & 0o777).toBe( + 0o600, + ); + const lines = await readPluginLogTail(dataDir, "example", 10); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("OPENAI_API_KEY=[REDACTED]"); + expect(lines[0]).not.toContain(credential); + }); +}); diff --git a/apps/server/test/services/plugins/plugin-wire.test.ts b/apps/server/test/services/plugins/plugin-wire.test.ts index ff7879dc35..fe146c4232 100644 --- a/apps/server/test/services/plugins/plugin-wire.test.ts +++ b/apps/server/test/services/plugins/plugin-wire.test.ts @@ -197,7 +197,7 @@ describe("plugin wire surfaces (http/rpc dispatcher + realtime)", () => { expect(appOrigin.status).toBe(200); }); - it("local auth rejects foreign origins but tolerates host-bound LAN/Tailscale serving", async () => { + it("local auth rejects foreign origins, including matching request authority", async () => { const foreignOrigin = await harness.app.request( `${BASE}/api/v1/plugins/wire/http/hello`, { headers: { origin: EVIL_ORIGIN } }, @@ -216,27 +216,25 @@ describe("plugin wire surfaces (http/rpc dispatcher + realtime)", () => { ); expect(copiedPort.status).toBe(403); - // Direct LAN/Tailscale serving binds the app origin to the request host. + // A same-origin request on an unconfigured LAN address is still hostile: + // the request URL and Host headers do not extend the allowlist. const sameOriginLan = await harness.app.request( "http://100.64.158.8:3334/api/v1/plugins/wire/http/hello", { headers: { origin: "http://100.64.158.8:3334" } }, ); - expect(sameOriginLan.status).toBe(200); + expect(sameOriginLan.status).toBe(403); const sameOriginReverseProxy = await harness.app.request( "https://bb.lan.test/api/v1/plugins/wire/http/hello", { headers: { origin: "https://bb.lan.test" } }, ); - expect(sameOriginReverseProxy.status).toBe(200); + expect(sameOriginReverseProxy.status).toBe(403); - // Direct development WebSockets use the dev origin hostname with the - // backend port, while Vite's HTTP proxy preserves the browser-facing host - // in X-Forwarded-Host. const directDev = await harness.app.request( "http://100.64.158.8:3334/api/v1/plugins/wire/http/hello", { headers: { origin: "http://100.64.158.8:5173" } }, ); - expect(directDev.status).toBe(200); + expect(directDev.status).toBe(403); const proxiedDev = await harness.app.request( `${BASE}/api/v1/plugins/wire/http/hello`, @@ -247,7 +245,14 @@ describe("plugin wire surfaces (http/rpc dispatcher + realtime)", () => { }, }, ); - expect(proxiedDev.status).toBe(200); + expect(proxiedDev.status).toBe(403); + + // The configured loopback dev origin remains a supported browser client. + const loopbackDev = await harness.app.request( + `${BASE}/api/v1/plugins/wire/http/hello`, + { headers: { origin: "http://127.0.0.1:5173" } }, + ); + expect(loopbackDev.status).toBe(200); }); it("local auth requires application/json on non-GET requests", async () => { diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 6dd4bc6d29..eca95bb6d2 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -733,7 +733,11 @@ unexpected-exit recovery without feature-specific core hooks. retaining only the most recently materialized artifact digest per plugin is sufficient. 7. **Environment.** Confirm executable discovery through normalized `PATH` - and stripping all daemon-owned `BB_*` variables. + and the minimal plugin/provider environment baseline. Host workers and + provider bridges must receive provider/plugin-specific credentials only + through explicit caller overlays; ambient secret-shaped variables are not + inherited. This does not sandbox in-process server plugins: they remain + full-trust Node programs. 8. **Trust and dependencies.** V1 host plugins are trusted Node programs that may use `child_process`, filesystem, and network APIs. Decide whether later permissions, native artifacts, or an explicit dependency installer can be diff --git a/docs/bb-release-process.md b/docs/bb-release-process.md index 4a5eb4352f..c06dd5cd1c 100644 --- a/docs/bb-release-process.md +++ b/docs/bb-release-process.md @@ -61,6 +61,14 @@ succeeds, so a nightly failure cannot affect the release that already shipped. release policy explicitly says to. - Always report the exact Git commit, npm version, dist-tags, validation, and workflow run status. +- Privileged manual paths are main-only. QA desktop builds and non-submitting + iOS EAS builds remain available from other branches. +- npm Trusted Publishing jobs install npm `11.6.2` exactly; do not replace that + pin with `npm@latest`. +- npm Trusted Publishing currently grants `id-token: write` at job scope while + that job installs dependencies and builds. Treat the locked dependency tree as + trusted; split build and publish into separate jobs before treating OIDC as a + hard isolation boundary. ## Inputs @@ -226,6 +234,16 @@ gh workflow run build-desktop.yml \ withholds the unsigned `.dmg`/`.zip` and publishes both version feeds plus the Linux AppImage. Linux has no notarization equivalent, so it never waits on the Apple secrets. +- The stable and nightly desktop publishers create a GitHub Artifact Attestation + for the release binaries before uploading them. Verify a downloaded asset + with `gh attestation verify -R get-bb/bb`. This is Sigstore-signed + publisher provenance, not a replacement for macOS Developer ID signing or a + code signature for Linux; Linux AppImages remain unsigned and their updater + keeps self-installing updates disabled until the updater can verify publisher + provenance independently; Linux still exposes feed metadata for version checks. + Artifact attestations also + require the repository plan and Actions `id-token`/`attestations` permissions + to be enabled. - The `desktop-v` release is immutable: if it already exists the workflow fails. Bump to a new version rather than re-running the same one. - The immutable `desktop-v` release owns GitHub's repository-wide diff --git a/docs/configuration.md b/docs/configuration.md index cd5f4e96fb..fe27004f68 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -98,7 +98,8 @@ Setting or unsetting one still runs the reload for any other pending changes, but the running processes keep their current values. Apply it with a full launcher restart (`bb-app stop && bb-app start`) or by restarting the desktop app. In particular, changing or unsetting `BB_SERVER_BIND_HOST` does not close -an existing `0.0.0.0` listener until that restart. +an existing listener until that restart. A listener from an older release that +was bound off-loopback remains exposed until it is restarted. `bb-app config refresh` also notes any startup-only keys currently present in `config.json` or `env.json`; those values apply on the next full restart. @@ -132,8 +133,8 @@ signal it, so a stale file left by a crash cannot stop an unrelated process. | `BB_INFERENCE_FALLBACK` | `bb-app config` | Optional | Helper model used after a transient primary timeout, rate limit, or service-unavailable failure. Defaults to `codex/gpt-5.4-mini`. | | `BB_TRANSCRIPTION` | `bb-app config` | Optional | Voice transcription model in `/` format: a plugin-registered AI service (`codex` with the codex plugin; audio up to 5MB) or `openai/` with `OPENAI_API_KEY`. Defaults to `codex/gpt-transcribe`. | | `BB_MARKETPLACE_URL` | `bb-app env`, or environment | Startup-only testing | Manifest URL of the reserved `bb-community` plugin marketplace, which lists as BB Community. Defaults to `https://getbb.app/marketplace/v1/marketplace.json`; point it at a local file server to test catalog refreshes. It sets only the reserved `bb-community` marketplace; other marketplaces are added at runtime with `bb marketplace add`. A full launcher or desktop app restart is required. | -| `BB_SERVER_URL` | `bb-app config` | Remote CLI/host use | Server URL for standalone `bb` CLI and `host-daemon` commands on the current machine. The CLI defaults to `http://127.0.0.1:38886` when unset. | -| `BB_SERVER_BIND_HOST` | `bb-app env`, environment, or `--server-bind-host` | Startup-only | Server listener host. Defaults to `127.0.0.1`; accepts only `127.0.0.1` or `0.0.0.0`. A full launcher or desktop app restart is required; until then, a previous `0.0.0.0` listener remains exposed. This is not a `bb-app config` key. | +| `BB_SERVER_URL` | `bb-app config` | Remote CLI/host use | Server URL for standalone `bb` CLI and `host-daemon` commands on the current machine. The CLI defaults to `http://127.0.0.1:38886` when unset; non-loopback URLs must use HTTPS. | +| `BB_SERVER_BIND_HOST` | `bb-app env`, environment, or `--server-bind-host` | Startup-only | Server listener host. The only accepted value is the loopback address `127.0.0.1`; off-loopback binding is refused because the public API is unauthenticated. A full launcher or desktop app restart is required. This is not a `bb-app config` key. | | `BB_SERVER_PORT` | `bb-app env`, environment, or `--server-port` | Startup-only | HTTP listener port. Defaults to `38886`. A full launcher or desktop app restart is required after a persistent set or unset. | | `BB_HOST_DAEMON_PORT` | `bb-app env`, environment, or `--host-daemon-port` | Startup-only | Local host-daemon API port. Defaults to `38887`. A full launcher or desktop app restart is required after a persistent set or unset. | | `BB_LOG_LEVEL` | `bb-app config` | Startup-only debugging | Log level: `trace`, `debug`, `info`, `warn`, `error`, or `fatal`. A full launcher or desktop app restart is required. | @@ -290,8 +291,8 @@ configuration. `BB_SERVER_URL` does not change where full `npx bb-app` startup binds locally. It is for commands that need to target an already-running server, such as the bundled `bb` CLI or a standalone host daemon. The CLI can omit it when targeting -the default local packaged server at `http://127.0.0.1:38886`; set it for remote -or non-default servers. +the default local packaged server at `http://127.0.0.1:38886`; set an HTTPS URL +for remote or non-default servers. ## Client SSH Targets @@ -521,16 +522,22 @@ machine. The current value is readable through the host API and Machine installation and daemon protocol repair use the owning server as the distribution source: `/install/version` reports the server package/protocol and -`/install/bb-app.tgz` serves its exact installable package. The installer falls -back to the npm registry only when the package route returns 404. It installs -the package under the machine's bb data directory rather than npm's system-wide -prefix, so enrollment needs neither `sudo` nor a global npm configuration. -Installed services enable `--auto-update`; remove that flag from the launchd -plist or systemd user unit and reload the service to opt out. Updates only move -to a newer server protocol, retry failures with a persisted exponential backoff -from 5 seconds to 5 minutes, and never downgrade a daemon. Settings → Machines -and `bb machine retry-update ` can bypass the current backoff after -a transient failure. +`/install/bb-app.tgz` serves its exact installable package. The installer requires +HTTPS for non-loopback servers (HTTP is allowed only for explicit loopback local +development), installs only that package, and fails if the route is unavailable. +It does not reuse PATH or fall back to the npm registry. It installs the package +under the machine's bb data directory rather than npm's system-wide prefix, so +enrollment needs neither `sudo` nor a global npm configuration. + +Installed services leave `--auto-update` off by default. Pass `--auto-update` to +the installer only after explicitly trusting the server's unsigned package for +unattended protocol repair; HTTPS protects transport but does not provide +publisher signing or digest binding. Updates only move to a newer server +protocol, retry failures with a persisted exponential backoff from 5 seconds to +5 minutes, and never downgrade a daemon. Settings → Machines and `bb machine +retry-update ` can bypass the current backoff after a transient +failure. Existing service files containing `--auto-update` must have that flag +removed manually to opt out, then be reloaded. ## Thread splits @@ -872,19 +879,19 @@ Use launcher flags for per-run startup details: npx bb-app --data-dir ~/.bb-test --server-port 48886 --host-daemon-port 48887 ``` -The server listens on `127.0.0.1` by default. Set -`--server-bind-host 0.0.0.0` (or `BB_SERVER_BIND_HOST=0.0.0.0`) only when a -trusted network boundary must reach the listener directly. The public API is -unauthenticated and permits command execution and file reads, so never expose a -wildcard-bound server to an untrusted network. The only accepted bind hosts are -`127.0.0.1` and `0.0.0.0`; this startup-only setting is not available through -`bb-app config`. +The server always listens on the IPv4 loopback address `127.0.0.1`. The public +API is unauthenticated and permits command execution, file access, terminal +control, thread changes, and plugin capabilities, so off-loopback binding is +refused rather than being an unsafe opt-in. `BB_SERVER_BIND_HOST` is retained +for compatibility but accepts only `127.0.0.1`; this startup-only setting is not +available through `bb-app config`. -The startup `Server listening` and `app` lines show the actual listener address. -With wildcard binding they show `http://0.0.0.0:`, while bb's health check -and colocated host daemon continue to connect through `127.0.0.1`. That local -connection does not narrow the listener. `0.0.0.0` exposes IPv4 interfaces only; -bb does not currently offer an IPv6 wildcard bind option. +The startup `Server listening` and `app` lines show the loopback listener. The +health check and colocated host daemon use the same address. For remote access, +use bb connect or publish the loopback listener through a private Tailscale +Serve route protected by Tailscale ACLs; do not expose the unauthenticated API +through a public proxy or Funnel. The Origin check is CSRF protection for +browsers, not authentication. The data directory is the root directory for all bb-managed state: the SQLite database, logs, host identity, thread storage, custom themes (`theme/`, @@ -896,6 +903,16 @@ checkout instance id is the sanitized path to the checkout, relative to your home directory, plus a short hash suffix. Use `--data-dir` to point packaged-app instances at different data directories for fully isolated environments. +bb creates or repairs its data and log directories with owner-only permissions +(`0700` on macOS/Linux) and uses restrictive file modes for sensitive files it +creates. This is access control, not encryption: prompts, events, attachments, +SQLite state, and logs remain readable to the local account (or a process with +that account's access), and existing files are not retroactively encrypted. +Protect the host account, use full-disk encryption and encrypted backups, and +avoid copying the data directory to an untrusted volume. Provider/plugin output +redaction is a best-effort safety net for recognizable credentials; it cannot +reliably identify arbitrary source fragments, PHI, or every secret format. + If the default ports are already in use, set explicit ports before starting: ```bash @@ -922,9 +939,9 @@ selectors (`BB_DATA_DIR`, server URL/port, host-daemon local API port, and Vite port) with deterministic values derived from the checkout path. The SQLite database path is always derived from `BB_DATA_DIR`. Both the main server and Vite app bind to loopback by default; an explicit `BB_DEV_APP_HOST` still -overrides the Vite listener. Remote HTTP dev via `BB_DEV_APP_HOST` also requires -`BB_SERVER_BIND_HOST=0.0.0.0` for realtime updates; the Tailscale Serve HTTPS -path avoids this because WebSocket traffic goes through the Vite proxy. +overrides the Vite listener. Remote HTTP development should use bb connect or +a private Tailscale Serve route; the server no longer supports direct +wildcard-bound development listeners. `pnpm start:worktree` loads the same development dotenv cascade and uses the same checkout-specific data directory, server port, and host-daemon port. It builds production artifacts and serves the frontend bundle from the main diff --git a/docs/multiple-devices.md b/docs/multiple-devices.md index ef2b978bcd..cc84d580cf 100644 --- a/docs/multiple-devices.md +++ b/docs/multiple-devices.md @@ -35,15 +35,14 @@ Settings → Machines so its installer records the account-gated route. The private alternative is to open bb through the Tailscale Serve URL and re-run the Add machine installer from there. -For compatibility only, `npx bb-app --server-bind-host 0.0.0.0` restores direct -IPv4 network access. The public API is unauthenticated and permits command -execution and file reads, so use wildcard binding only behind a trusted network -boundary and never through Funnel or the public internet. +The server does not support direct LAN or tailnet binding: its unauthenticated +API always listens on loopback, and off-loopback `BB_SERVER_BIND_HOST` values are +refused. Use bb connect or Tailscale Serve with private ACLs for remote access. +The Origin check is a browser CSRF boundary, not authentication. -Inside a container, `0.0.0.0` listens on the container's IPv4 interfaces; the -container runtime must still publish that port to the host (for example, -`docker run -p 3000:3000 ...`). Host firewall and upstream network rules also -remain separate from bb's bind setting. +Inside a container, publish a loopback listener through a private access +boundary rather than exposing the unauthenticated API itself. Host firewall and +upstream network rules remain separate from bb's bind setting. ### Use editors installed on the browser device @@ -106,10 +105,10 @@ The phone keeps its credential in the device keychain and mints short-lived sessions from it; it never holds the server's pairing secret. To cut a phone off, revoke it in the getbb.app dashboard machine list. Every phone takes one of the account's machine slots, so a machine-limit error means an unused device -should be revoked first. On a trusted network the app can also use a direct -server URL (Tailscale Serve or `--server-bind-host 0.0.0.0`) with the same -caveats as a browser. Platforms (iOS first) and what the phone cannot do are -listed in [platform-support.md](platform-support.md). +should be revoked first. The app can use a Tailscale Serve URL protected by +private ACLs, or bb connect; direct LAN/tailnet server URLs are not supported. +Platforms (iOS first) and what the phone cannot do are listed in +[platform-support.md](platform-support.md). ## Point the desktop app at another bb @@ -140,17 +139,20 @@ not directly reachable from another machine. When bb connect is not paired and the server URL is a loopback or unspecified address, the dialog does not show an installer. It links to Settings → Remote access instead. -The installer always installs the exact `bb-app` package exposed by that -server at `/install/bb-app.tgz`; a `bb-app` already on PATH is reused, and the -npm registry consulted, only when the server provides no package. Version -strings cannot distinguish unpublished builds, so this keeps remote machines -aligned with development and pre-release servers whose build may not exist on -npm. The package route is public like `/install.sh`: `bb-app` is public -software, and exposing an unpublished build slightly early through a paired -tunnel is an accepted tradeoff. npm installs the package into the machine's bb -data directory, not its system-wide global prefix, so enrollment needs neither +The installer requires HTTPS for non-loopback server URLs; HTTP is accepted only +for explicit loopback local development (`127.0.0.1`, `localhost`, or `::1`). It +installs only the exact `bb-app` package exposed by that server at +`/install/bb-app.tgz` and fails if the route is unavailable. It never reuses a +`bb-app` from PATH or falls back to the npm registry, so enrollment cannot +silently change publishers. npm installs the package into the machine's bb data +directory, not its system-wide global prefix, so enrollment needs neither `sudo` nor a PATH change. +The package route is public like `/install.sh`. The initial exact-package +installation is an intentional consequence of running the installer for this +server; the package is not currently publisher-signed or client-side digest +bound, so use a trusted HTTPS server and review the server/build pipeline. + Each joined server gets its own daemon instance, data directory (`~/.bb-machines/`, override with `BB_DATA_DIR` when running the installer), local API port, and launchd/systemd service. The installer persists @@ -160,20 +162,23 @@ elsewhere. Subsequent runs reuse the reservation; pass `--host-daemon-port ` to the installer to override the selection. One machine can therefore serve several bb servers at once, and joining never touches a full local bb install's `~/.bb`. Each instance keeps its own `bb-app` under that data -directory and self-updates against its own server, so servers running different -bb versions on one machine remain isolated. - -The installed launchd/systemd service enables `--auto-update`. If session open -reports a newer server protocol, the daemon downloads the server artifact, -updates its private install, then exits so the service manager restarts it. -Failed attempts fall back to normal reconnect behavior with a persisted -exponential retry backoff from 5 seconds to 5 minutes. Settings → Machines and -`bb machine retry-update ` can bypass the current backoff. A daemon -never downgrades itself to an older server protocol. To opt out, remove -`--auto-update` from -`~/Library/LaunchAgents/app.getbb.host-daemon..plist` or -`~/.config/systemd/user/bb-host-daemon-.service`, then reload the -service. +directory and can self-update against its own server when the operator has +explicitly opted in, so servers running different bb versions on one machine +remain isolated. + +The installed launchd/systemd service leaves unattended daemon auto-update off +by default. If the operator explicitly passes `--auto-update` to the installer, +the service may, on a newer server protocol, download the server artifact, update +its private install, and exit so the service manager restarts it. This is an +explicit trust decision for an unsigned server-delivered package; HTTPS protects +transport but does not replace publisher signing or digest verification. Keep +this opt-in disabled until that distribution contract exists unless the server +and its build pipeline are trusted. Failed attempts fall back to normal +reconnect behavior with a persisted exponential retry backoff from 5 seconds to +5 minutes. Settings → Machines and `bb machine retry-update ` can +bypass the current backoff. A daemon never downgrades itself to an older server +protocol. Existing service files that already contain `--auto-update` must have +that flag removed manually to opt out, then the service reloaded. After it connects: diff --git a/docs/platform-support.md b/docs/platform-support.md index 60cb1226bd..0df1a2ed07 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -42,14 +42,13 @@ talks to a server over the same HTTP + WebSocket contract as the web app. - Platforms: iOS first (iPhone; iPad runs the phone layout). Android is planned next; the code is platform-neutral but no Android build has been produced or tested yet. -- Connecting: **Direct** mode takes any `http(s)://` URL the phone can reach - (the iOS Simulator's `http://127.0.0.1:`, a LAN address with - `--server-bind-host 0.0.0.0`, a Tailscale Serve HTTPS URL). It is - unauthenticated, the same trust model as the browser PWA on a LAN; iOS - allows plain `http://` only for LAN IPs and `.local` names, so Tailscale - hosts need Serve HTTPS. **bb connect** mode pairs the phone as a connect - machine (QR / code from Settings → Remote access or - `bb connect machine-code`, both behind the `mobileApp` experiment during +- Connecting: **Direct** mode supports the iOS Simulator's + `http://127.0.0.1:` loopback URL and private Tailscale Serve HTTPS URLs. + The server no longer supports direct LAN or tailnet binding because its API + is unauthenticated. iOS allows plain `http://` only for loopback, LAN IPs, + and `.local` names, so Tailscale hosts need Serve HTTPS. **bb connect** mode + pairs the phone as a connect machine (QR / code from Settings → Remote access + or `bb connect machine-code`, both behind the `mobileApp` experiment during early access), keeps the credential in the device keychain, and mints short-lived sessions; see [multiple-devices.md](multiple-devices.md). - Distribution: developer builds from source (Xcode 26.2, iOS 26 simulator diff --git a/package.json b/package.json index 1e72357820..aabb006f1c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "ensure-native-modules": "node scripts/ensure-native-modules.mjs", "dev": "node scripts/ensure-native-modules.mjs && cross-env NODE_ENV=development dotenv -c development -- node --conditions=source --import tsx packages/scripts/src/commands/run-dev.ts", "start:worktree": "cross-env NODE_ENV=development dotenv -c development -- node --conditions=source --import tsx packages/scripts/src/commands/run-dev.ts --worktree", - "dev:remote": "cross-env BB_DEV_APP_HOST=0.0.0.0 BB_SERVER_BIND_HOST=0.0.0.0 pnpm run dev", "cloud:dev": "node --conditions=source --import tsx scripts/bb-cloud-dev.mjs", "dev:desktop": "scripts/bb-dev-app current --desktop", "dev:status": "scripts/bb-dev-app status", @@ -77,7 +76,10 @@ }, "overrides": { "zod": "4.3.6", - "@expo/metro-config>lightningcss": "1.30.1" + "@expo/metro-config>lightningcss": "1.30.1", + "@ungap/structured-clone": "1.3.4", + "@xmldom/xmldom@0.8.13": "0.8.15", + "@xmldom/xmldom@0.9.10": "0.9.12" }, "patchedDependencies": { "expo-modules-jsi@57.0.4": "patches/expo-modules-jsi@57.0.4.patch", diff --git a/packages/agent-runtime/src/runtime-provider-process.ts b/packages/agent-runtime/src/runtime-provider-process.ts index f73fed96d7..49eb2184f6 100644 --- a/packages/agent-runtime/src/runtime-provider-process.ts +++ b/packages/agent-runtime/src/runtime-provider-process.ts @@ -2,7 +2,7 @@ import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { - sanitizeInheritedChildProcessEnv, + sanitizePluginProcessEnv, killProcessGroup, spawnPortablePipedProcess, stopProcessGroupLeaderFirst, @@ -411,7 +411,10 @@ export class RuntimeProviderProcessManager { private spawnProvider(args: SpawnProviderArgs): RuntimeProviderProcess { const processConfig = args.adapter.process; const env: NodeJS.ProcessEnv = { - ...sanitizeInheritedChildProcessEnv({ env: process.env }), + // The bridge is plugin-delivered code. Start it with only the + // execution baseline, then overlay values explicitly supplied by the + // runtime and the provider adapter (including credentials). + ...sanitizePluginProcessEnv({ env: process.env }), ...this.args.env, ...processConfig.env, }; diff --git a/packages/agent-runtime/src/runtime.process-lifecycle.test.ts b/packages/agent-runtime/src/runtime.process-lifecycle.test.ts index d725db5d6b..bfd84fa01c 100644 --- a/packages/agent-runtime/src/runtime.process-lifecycle.test.ts +++ b/packages/agent-runtime/src/runtime.process-lifecycle.test.ts @@ -1333,17 +1333,21 @@ describe("createAgentRuntime process lifecycle", () => { // ---- Spawn environment ---- - it("scrubs inherited bb runtime env vars before spawning provider processes", async () => { + it("uses explicit provider env without forwarding ambient secrets", async () => { + vi.stubEnv("AWS_SECRET_ACCESS_KEY", "ambient-aws-secret"); vi.stubEnv("BB_DATA_DIR", "/tmp/leaked-bb-data"); vi.stubEnv("BB_SERVER_PORT", "38886"); + vi.stubEnv("GITHUB_TOKEN", "ambient-github-secret"); vi.stubEnv("NODE_ENV", "development"); - vi.stubEnv("OPENAI_API_KEY", "external-secret"); + vi.stubEnv("OPENAI_API_KEY", "ambient-openai-secret"); const envScript = join(tmpDir, "env-provider.cjs"); writeFileSync( envScript, `const values = [ + process.env.AWS_SECRET_ACCESS_KEY ?? "missing", process.env.BB_DATA_DIR ?? "missing", process.env.BB_SERVER_PORT ?? "missing", + process.env.GITHUB_TOKEN ?? "missing", process.env.NODE_ENV ?? "missing", process.env.OPENAI_API_KEY ?? "missing", process.env.BB_THREAD_ID ?? "missing" @@ -1355,6 +1359,7 @@ describe("createAgentRuntime process lifecycle", () => { const manager = createProviderProcessManager({ env: { BB_THREAD_ID: "thr_explicit", + OPENAI_API_KEY: "explicit-provider-secret", }, onProcessExit: vi.fn(), onStderr: (line) => { @@ -1379,7 +1384,7 @@ describe("createAgentRuntime process lifecycle", () => { predicate: () => stderrLines.length > 0, }); expect(stderrLines[0]).toBe( - "missing|missing|missing|external-secret|thr_explicit", + "missing|missing|missing|missing|missing|explicit-provider-secret|thr_explicit", ); await manager.shutdown(); await ensure; diff --git a/packages/bb-app/README.md b/packages/bb-app/README.md index 051dcc361a..3e64f0e204 100644 --- a/packages/bb-app/README.md +++ b/packages/bb-app/README.md @@ -135,8 +135,9 @@ console.log(await bb.threads.output({ threadId: String(thread.id) })); ``` `new BBSdk()` uses the same `BB_SERVER_URL` and bb config resolution as the -CLI. Pass `new BBSdk({ baseUrl: "http://host:38886" })` for remote or test -targets (see the remote-access note below). Scripts launched by bb already receive `BB_SERVER_URL` and +CLI. Pass `new BBSdk({ baseUrl: "https://host.example.test" })` for remote +targets, or a loopback HTTP URL for local tests (see the remote-access note +below). Scripts launched by bb already receive `BB_SERVER_URL` and `BB_THREAD_ID` in their environment. ## Provider Credentials @@ -198,9 +199,8 @@ npx bb-app config refresh ``` For remote access, use bb connect or publish the default loopback listener with -Tailscale Serve. Direct tailnet or LAN access to port `38886` requires the -explicit, security-sensitive `--server-bind-host 0.0.0.0` compatibility option; -see the multiple-devices guide. +Tailscale Serve. Direct tailnet or LAN binding is not supported because the +server API is unauthenticated; see the multiple-devices guide. Use `bb-app client ssh-target` to configure local editor opens for remote bb servers under `~/.bb/client.json`. The target is the value that works after diff --git a/packages/bb-app/src/launcher.ts b/packages/bb-app/src/launcher.ts index 500feb1a3a..8bb4c4b1df 100644 --- a/packages/bb-app/src/launcher.ts +++ b/packages/bb-app/src/launcher.ts @@ -5,6 +5,7 @@ import { spawn } from "node:child_process"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { access, + chmod, mkdir, readFile, rename, @@ -55,7 +56,7 @@ import { validateTranscriptionModel, } from "@bb/config/inference-model"; import { validateLogLevel } from "@bb/config/log-level"; -import { validateOptionalUrl } from "@bb/config/public-url"; +import { validateOptionalUrl, validateServerUrl } from "@bb/config/public-url"; import { parseServerBindHost, type ServerBindHost } from "@bb/config/server"; import { toOptionalString } from "@bb/config/strings"; import { @@ -921,12 +922,12 @@ function createEnvFromOptions( } function resolveServerUrl(args: ResolveServerUrlArgs): string { - return ( + const serverUrl = toOptionalString(args.optionServerUrl) ?? args.config.serverUrl ?? toOptionalString(args.env.BB_SERVER_URL) ?? - args.defaultServerUrl - ); + args.defaultServerUrl; + return validateServerUrl("BB_SERVER_URL", serverUrl); } export function resolveServerListenerUrl( @@ -1197,11 +1198,16 @@ function pruneManagedEnvFile(config: ManagedEnvFile): ManagedEnvFile { return nextConfig; } +async function ensurePrivateDataDirectory(dataDir: string): Promise { + await mkdir(dataDir, { mode: 0o700, recursive: true }); + await chmod(dataDir, 0o700); +} + async function writeManagedConfigFile( args: WriteManagedConfigFileArgs, ): Promise { validateManagedConfigForWrite(args.config); - await mkdir(args.dataDir, { recursive: true }); + await ensurePrivateDataDirectory(args.dataDir); const nextConfig = pruneManagedConfig(args.config); const configPath = formatBbAppConfigPath(args.dataDir); const tempPath = join( @@ -1223,7 +1229,7 @@ async function writeManagedConfigFile( function validateManagedConfigForWrite(config: ManagedConfigForWrite): void { if (config.serverUrl !== undefined) { - validateOptionalUrl("BB_SERVER_URL", config.serverUrl); + validateServerUrl("BB_SERVER_URL", config.serverUrl); } const configValues = config.config; if (configValues === undefined) { @@ -1267,7 +1273,7 @@ async function writeManagedEnv(args: WriteManagedEnvFileArgs): Promise { async function writeManagedEnvFile( args: WriteManagedEnvFileArgs, ): Promise { - await mkdir(args.dataDir, { recursive: true }); + await ensurePrivateDataDirectory(args.dataDir); const nextConfig = pruneManagedEnvFile(args.config); const envPath = formatBbAppEnvPath(args.dataDir); const tempPath = join( @@ -1290,7 +1296,7 @@ async function writeManagedEnvFile( async function writeClientConfigFile( args: WriteClientConfigFileArgs, ): Promise { - await mkdir(args.dataDir, { recursive: true }); + await ensurePrivateDataDirectory(args.dataDir); const configPath = formatClientConfigPath(args.dataDir); const tempPath = join( args.dataDir, @@ -1860,7 +1866,7 @@ function printStartupOnlyChangeNotice(key: string): void { ); if (key === "BB_SERVER_BIND_HOST") { process.stdout.write( - "Until then, the server keeps its previous bind address. If it was bound to 0.0.0.0, that network exposure remains open.\n", + "Restart bb-app before relying on this change; an older process may still have an off-loopback listener.\n", ); } } diff --git a/packages/bb-app/test/index.test.ts b/packages/bb-app/test/index.test.ts index d0f4a46ecb..4e21ea9407 100644 --- a/packages/bb-app/test/index.test.ts +++ b/packages/bb-app/test/index.test.ts @@ -1,5 +1,7 @@ import { execFileSync, spawn } from "node:child_process"; import { + chmodSync, + existsSync, mkdirSync, mkdtempSync, readdirSync, @@ -33,7 +35,6 @@ import { resolveDataDir, resolveBbAppStartContext, resolveBbAppCommand, - resolveServerListenerUrl, resolveWorktreeRuntimePolicy, runBbApp, runBundledCliCommand, @@ -765,26 +766,12 @@ describe("bb-app launcher", () => { }); }); - it("reports the server bind host separately from the loopback connection URL", async () => { - const parsedArgs = parseLauncherArgs(["--server-bind-host", "0.0.0.0"]); + it("rejects a non-loopback server bind host before startup", async () => { const dataDir = mkdtempSync(join(tmpdir(), "bb-app-bind-host-")); - const runtime = await resolveBbAppRuntimeState({ - entrypointUrl: pathToFileURL("/repo/packages/bb-app/dist/bb-app.js").href, - env: { BB_DATA_DIR: dataDir }, - homeDir: "/home/tester", - options: parsedArgs.options, - serverUrlMode: "local", - }); - expect(parsedArgs.options.serverBindHost).toBe("0.0.0.0"); - expect(runtime.serverEnv.BB_SERVER_BIND_HOST).toBe("0.0.0.0"); - expect( - resolveServerListenerUrl({ - bindHost: runtime.serverEnv.BB_SERVER_BIND_HOST, - port: runtime.context.serverPort, - }), - ).toBe("http://0.0.0.0:38886"); - expect(runtime.context.serverUrl).toBe("http://127.0.0.1:38886"); + await expect( + runBbApp(["--data-dir", dataDir, "--server-bind-host", "0.0.0.0"]), + ).rejects.toThrow(/loopback/u); }); it("strips parent thread context from the production server without stripping the CLI", async () => { @@ -840,7 +827,7 @@ describe("bb-app launcher", () => { await expect( runBbApp(["--data-dir", dataDir, "--server-bind-host", "localhost"]), - ).rejects.toThrow('BB_SERVER_BIND_HOST must be "127.0.0.1" or "0.0.0.0"'); + ).rejects.toThrow(/loopback/u); }); it("uses a supplied join code without requesting a loopback enroll key", async () => { @@ -1010,7 +997,7 @@ describe("bb-app launcher", () => { BB_DEV_APP_PORT: "4173", BB_HOST_DAEMON_PORT: "48887", BB_INHERITED_SKILLS_ROOTS: "/stored/skills", - BB_SERVER_BIND_HOST: "0.0.0.0", + BB_SERVER_BIND_HOST: "127.0.0.1", BB_SERVER_PORT: "48886", BB_TELEMETRY: "true", OPENAI_API_KEY: "stored-openai-key", @@ -1085,6 +1072,7 @@ describe("bb-app launcher", () => { it("stores managed config values from the config command", async () => { const dataDir = mkdtempSync(join(tmpdir(), "bb-app-config-command-")); + chmodSync(dataDir, 0o755); await runBbApp([ "--data-dir", @@ -1137,6 +1125,7 @@ describe("bb-app launcher", () => { ); expect(statSync(join(dataDir, "config.json")).mode & 0o777).toBe(0o600); expect(statSync(join(dataDir, "env.json")).mode & 0o777).toBe(0o600); + expect(statSync(dataDir).mode & 0o777).toBe(0o700); }); it("stores client SSH targets from the client command", async () => { @@ -1429,6 +1418,22 @@ describe("bb-app launcher", () => { ).rejects.toThrow(/bb-app env set OPENAI_API_KEY/u); }); + it("rejects remote HTTP server URLs before writing managed config", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "bb-app-remote-http-config-")); + + await expect( + runBbApp([ + "--data-dir", + dataDir, + "config", + "set", + "BB_SERVER_URL", + "http://server.example.test:38886", + ]), + ).rejects.toThrow(/HTTPS/u); + expect(existsSync(join(dataDir, "config.json"))).toBe(false); + }); + it("stores managed env values from the env command", async () => { const dataDir = mkdtempSync(join(tmpdir(), "bb-app-env-command-")); @@ -1468,7 +1473,7 @@ describe("bb-app launcher", () => { "BB_SERVER_BIND_HOST", "localhost", ]), - ).rejects.toThrow('BB_SERVER_BIND_HOST must be "127.0.0.1" or "0.0.0.0"'); + ).rejects.toThrow(/loopback/u); expect(JSON.parse(readFileSync(envPath, "utf8"))).toEqual(initialEnvFile); }); @@ -1674,7 +1679,7 @@ describe("bb-app launcher", () => { } }); - it("warns that wildcard exposure remains after unsetting the server bind host", async () => { + it("recovers from a legacy wildcard bind setting when unsetting it", async () => { const dataDir = mkdtempSync(join(tmpdir(), "bb-app-startup-bind-unset-")); writeFileSync( join(dataDir, "env.json"), @@ -1700,7 +1705,7 @@ describe("bb-app launcher", () => { "BB_SERVER_BIND_HOST is startup-only. The running process keeps its current value; a full bb-app restart is required to apply this change. Run `bb-app stop && bb-app start`, or restart the desktop app.", ); expect(output).toContain( - "Until then, the server keeps its previous bind address. If it was bound to 0.0.0.0, that network exposure remains open.", + "Restart bb-app before relying on this change; an older process may still have an off-loopback listener.", ); expect(output).not.toContain("Reloaded running bb server config."); expect(server.reloadRequests()).toEqual([ @@ -1750,8 +1755,8 @@ describe("bb-app launcher", () => { String(unavailablePort), "env", "set", - "BB_SERVER_BIND_HOST", - "0.0.0.0", + "BB_SERVER_PORT", + "48886", ]), ); @@ -1771,7 +1776,7 @@ describe("bb-app launcher", () => { JSON.stringify({ env: { BB_FF_PLACEHOLDER: "true", - BB_SERVER_BIND_HOST: "0.0.0.0", + BB_SERVER_BIND_HOST: "127.0.0.1", BB_SERVER_PORT: "48886", BB_TELEMETRY: "false", }, diff --git a/packages/config/src/env-vars.ts b/packages/config/src/env-vars.ts index 3fc8b7b1aa..19512c829f 100644 --- a/packages/config/src/env-vars.ts +++ b/packages/config/src/env-vars.ts @@ -15,10 +15,10 @@ import { validateTranscriptionModel, } from "./inference-model.js"; import { validateLogLevel } from "./log-level.js"; -import { validateOptionalUrl, validateRequiredUrl } from "./public-url.js"; +import { validateOptionalUrl, validateServerUrl } from "./public-url.js"; import { BB_LOOPBACK_HOST, parsePortValue } from "./runtime.js"; -export type ServerBindHost = "127.0.0.1" | "0.0.0.0"; +export type ServerBindHost = "127.0.0.1"; function parseBooleanEnvValue(args: EnvVarParseArgs): boolean { const normalizedValue = args.value.trim().toLowerCase(); @@ -95,12 +95,13 @@ function parsePortEnvValue(args: EnvVarParseArgs): number { } export function parseServerBindHost(value: string): ServerBindHost { - const trimmedValue = value.trim(); - if (trimmedValue === "127.0.0.1" || trimmedValue === "0.0.0.0") { - return trimmedValue; + if (value.trim() === "127.0.0.1") { + return "127.0.0.1"; } - throw new Error('BB_SERVER_BIND_HOST must be "127.0.0.1" or "0.0.0.0"'); + throw new Error( + 'BB_SERVER_BIND_HOST must be the loopback address "127.0.0.1"', + ); } function parseServerBindHostEnvValue(args: EnvVarParseArgs): ServerBindHost { @@ -115,8 +116,8 @@ function parsePositiveIntegerEnvValue(args: EnvVarParseArgs): number { return parsed; } -function parseRequiredUrlEnvValue(args: EnvVarParseArgs): string { - return validateRequiredUrl(args.name, args.value); +function parseServerUrlEnvValue(args: EnvVarParseArgs): string { + return validateServerUrl(args.name, args.value); } function parseOptionalUrlEnvValue(args: EnvVarParseArgs): string { @@ -166,7 +167,7 @@ export const BB_SERVER_PORT_ENV = defineEnvVar({ }); export const BB_SERVER_BIND_HOST_ENV = defineEnvVar({ - description: "HTTP bind host for the server", + description: "Loopback HTTP bind host for the server", name: "BB_SERVER_BIND_HOST", parse: parseServerBindHostEnvValue, }); @@ -178,9 +179,9 @@ export const BB_HOST_DAEMON_PORT_ENV = defineEnvVar({ }); export const BB_SERVER_URL_ENV = defineEnvVar({ - description: "URL of the bb server", + description: "HTTPS URL of the bb server, or HTTP loopback for development", name: "BB_SERVER_URL", - parse: parseRequiredUrlEnvValue, + parse: parseServerUrlEnvValue, }); export const BB_APP_VERSION_ENV = defineEnvVar({ diff --git a/packages/config/src/public-url.ts b/packages/config/src/public-url.ts index 38be78cfb6..7fb9ccc581 100644 --- a/packages/config/src/public-url.ts +++ b/packages/config/src/public-url.ts @@ -1,3 +1,5 @@ +import { isLoopbackHostname } from "./loopback.js"; + export function validateOptionalUrl(name: string, value: string): string { const trimmedValue = value.trim(); if (trimmedValue.length === 0) { @@ -19,3 +21,24 @@ export function validateRequiredUrl(name: string, value: string): string { throw new Error(`${name} must be a valid URL, received "${value}"`); } } + +/** + * Validate a URL used to carry bb API credentials or workspace data. + * Plain HTTP is safe only for loopback/local-development hostnames. + */ +export function validateServerUrl(name: string, value: string): string { + const validated = validateRequiredUrl(name, value); + const parsed = new URL(validated); + if (parsed.protocol === "https:") { + return validated; + } + const hostname = parsed.hostname.toLowerCase(); + const loopback = + isLoopbackHostname(hostname) || hostname.endsWith(".localhost"); + if (parsed.protocol === "http:" && loopback) { + return validated; + } + throw new Error( + `${name} must use HTTPS unless it targets loopback/local development`, + ); +} diff --git a/packages/config/src/server-url.ts b/packages/config/src/server-url.ts index 13c5cc6754..f766596b2d 100644 --- a/packages/config/src/server-url.ts +++ b/packages/config/src/server-url.ts @@ -5,7 +5,7 @@ import { type EnvLoaderArgs, } from "./env.js"; import { BB_SERVER_URL_ENV } from "./env-vars.js"; -import { validateRequiredUrl } from "./public-url.js"; +import { validateServerUrl } from "./public-url.js"; import { resolveDevInstanceConfig } from "./runtime.js"; interface LoadServerUrlValueArgs extends EnvLoaderArgs { @@ -15,7 +15,7 @@ interface LoadServerUrlValueArgs extends EnvLoaderArgs { export function loadServerUrlValue(args: LoadServerUrlValueArgs = {}): string { if (args.serverUrl !== undefined) { - return validateRequiredUrl(BB_SERVER_URL_ENV.name, args.serverUrl); + return validateServerUrl(BB_SERVER_URL_ENV.name, args.serverUrl); } const loader = resolveEnvLoader(args); diff --git a/packages/config/test/config.test.ts b/packages/config/test/config.test.ts index 7cd1c18291..c157fb5459 100644 --- a/packages/config/test/config.test.ts +++ b/packages/config/test/config.test.ts @@ -337,14 +337,14 @@ describe("consumer-specific config", () => { expect(serverConfig.BB_SERVER_BIND_HOST).toBe("127.0.0.1"); }); - it("honors an explicit wildcard server bind host", () => { - const serverConfig = loadServerConfig({ - env: createServerRuntimeEnv({ - BB_SERVER_BIND_HOST: "0.0.0.0", + it("rejects an explicit non-loopback server bind host", () => { + expect(() => + loadServerConfig({ + env: createServerRuntimeEnv({ + BB_SERVER_BIND_HOST: "0.0.0.0", + }), }), - }); - - expect(serverConfig.BB_SERVER_BIND_HOST).toBe("0.0.0.0"); + ).toThrow(/loopback/u); }); it("rejects an unsupported server bind host", () => { @@ -534,6 +534,24 @@ describe("consumer-specific config", () => { ).toThrow(/BB_SERVER_URL/u); }); + it("requires HTTPS for non-loopback server URLs", () => { + expect(() => + loadCliConfig({ + env: createHostDaemonRuntimeEnv({ + BB_SERVER_URL: "http://server.example.test:38886", + }), + }), + ).toThrow(/HTTPS/u); + + expect( + loadCliConfig({ + env: createHostDaemonRuntimeEnv({ + BB_SERVER_URL: "https://server.example.test:38886", + }), + }).BB_SERVER_URL, + ).toBe("https://server.example.test:38886"); + }); + it("normalizes server URL whitespace consistently for the daemon and CLI", () => { const env = createHostDaemonRuntimeEnv({ BB_SERVER_URL: " http://localhost:9999 ", diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index 05a1be16b9..cad04721c4 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -1,8 +1,15 @@ -import { mkdirSync } from "node:fs"; import { join } from "node:path"; import pino from "pino"; import type { Logger } from "pino"; import { loadLoggerConfig } from "@bb/config/logger"; +import { + ensurePrivateDirectory, + redactSensitiveLogObject, + redactSensitiveLogValue, + redactSensitiveText, +} from "./privacy.js"; + +export { ensurePrivateDirectory, redactSensitiveText } from "./privacy.js"; export type { Logger }; @@ -29,16 +36,39 @@ export function createLogger(options: CreateLoggerOptions): Logger { const loggerConfig = loadLoggerConfig({ dataDir: options.dataDir }); const dataDir = loggerConfig.BB_DATA_DIR; const logDir = join(dataDir, "logs"); - mkdirSync(logDir, { recursive: true }); + ensurePrivateDirectory(dataDir); + ensurePrivateDirectory(logDir); const loggerOptions = { level: loggerConfig.BB_LOG_LEVEL, base: { component, ...(options.base ?? {}), }, + formatters: { + bindings: (bindings) => redactSensitiveLogObject(bindings), + log: (object) => redactSensitiveLogObject(object), + }, + hooks: { + logMethod(inputArgs, method) { + const firstArgument = redactSensitiveLogValue(inputArgs[0]); + if (inputArgs.length === 1) { + method.call(this, firstArgument); + return; + } + const message = inputArgs[1]; + method.call( + this, + firstArgument, + message === undefined ? undefined : redactSensitiveText(message), + ...inputArgs.slice(2).map(redactSensitiveLogValue), + ); + }, + }, serializers: { - err: pino.stdSerializers.errWithCause, - error: pino.stdSerializers.errWithCause, + err: (error: Error) => + redactSensitiveLogValue(pino.stdSerializers.errWithCause(error)), + error: (error: Error) => + redactSensitiveLogValue(pino.stdSerializers.errWithCause(error)), }, } satisfies pino.LoggerOptions; const transportMode = options.transportMode ?? "worker"; diff --git a/packages/logger/src/privacy.ts b/packages/logger/src/privacy.ts new file mode 100644 index 0000000000..f548ee621c --- /dev/null +++ b/packages/logger/src/privacy.ts @@ -0,0 +1,151 @@ +import { chmodSync, mkdirSync } from "node:fs"; + +export const PRIVATE_DIRECTORY_MODE = 0o700; +const REDACTED_VALUE = "[REDACTED]"; + +const SENSITIVE_KEY_PATTERN = + /(?:api[_-]?key|access[_-]?(?:key|token)|auth(?:orization)?|client[_-]?secret|credential(?:s)?|password|passphrase|private[_-]?key|refresh[_-]?token|secret|token)/iu; +const SENSITIVE_ASSIGNMENT_PATTERN = + /((?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|access[_-]?(?:key|token)|client[_-]?secret|credential(?:s)?|password|passphrase|private[_-]?key|refresh[_-]?token|secret|token)(?:[_-][A-Za-z0-9]+)*(?:["']?\s*[:=]\s*))(\[REDACTED\]|"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;}"\]]+)/giu; +const AUTHORIZATION_ASSIGNMENT_PATTERN = + /((?:authorization|auth)(?:["']?\s*[:=]\s*))(?:(Bearer|Basic)\s+)?(\[REDACTED\]|[^\s,;}"\]]+)/giu; +const AUTHORIZATION_VALUE_PATTERN = + /\b(?:Bearer|Basic)\s+(?:\[REDACTED\]|[^\s,;}"\]]+)/giu; +const PRIVATE_KEY_PATTERN = + /-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/giu; +const SENSITIVE_MARKER_PATTERN = + /PRIVATE KEY|api[_-]?key|access[_-]?(?:key|token)|authorization|client[_-]?secret|credential|password|passphrase|private[_-]?key|refresh[_-]?token|secret|token|Bearer|Basic|sk-|gh[pousr]_|github_pat_|xox[baprs]-|npm_|pypi-|AIza|AKIA|ASIA|eyJ|bb(?:cm|hk)_/iu; +const TOKEN_PATTERNS: readonly RegExp[] = [ + /\bsk-(?:ant-)?[A-Za-z0-9_-]{8,}\b/gu, + /\b(?:gh[pousr]_[A-Za-z0-9_]{8,}|github_pat_[A-Za-z0-9_]{8,})\b/gu, + /\bxox[baprs]-[A-Za-z0-9-]{8,}\b/gu, + /\b(?:npm_|pypi-)[A-Za-z0-9_-]{8,}\b/gu, + /\bAIza[A-Za-z0-9_-]{20,}\b/gu, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/gu, + /\bbb(?:cm|hk)_[A-Za-z0-9_-]{8,}\b/gu, +]; + +function redactReplacement(value: string): string { + const first = value[0]; + const last = value.at(-1); + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return `${first}${REDACTED_VALUE}${last}`; + } + return REDACTED_VALUE; +} + +/** + * Replaces credential-shaped values while retaining the surrounding + * diagnostic context. This is deliberately pattern-based: it is a safety net + * for provider/plugin output, not encryption or a substitute for OS storage + * protections. + */ +export function redactSensitiveText(text: string): string { + if (!SENSITIVE_MARKER_PATTERN.test(text)) { + return text; + } + + let redacted = text; + if (/PRIVATE KEY/iu.test(redacted)) { + redacted = redacted.replace(PRIVATE_KEY_PATTERN, REDACTED_VALUE); + } + if (/authorization|\bauth\b/iu.test(redacted)) { + redacted = redacted.replace( + AUTHORIZATION_ASSIGNMENT_PATTERN, + (_match, prefix: string, scheme: string | undefined, value: string) => + `${prefix}${scheme === undefined ? "" : `${scheme} `}${redactReplacement(value)}`, + ); + } + redacted = redacted.replace( + AUTHORIZATION_VALUE_PATTERN, + (match) => `${match.split(/\s+/u)[0]} ${REDACTED_VALUE}`, + ); + if (SENSITIVE_KEY_PATTERN.test(redacted)) { + redacted = redacted.replace( + SENSITIVE_ASSIGNMENT_PATTERN, + (_match, prefix: string, value: string) => + `${prefix}${redactReplacement(value)}`, + ); + } + for (const pattern of TOKEN_PATTERNS) { + redacted = redacted.replace(pattern, REDACTED_VALUE); + } + return redacted; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function redactSensitiveValue( + value: unknown, + key: string | undefined, + seen: Map, +): unknown { + if (typeof value === "string") { + return key !== undefined && SENSITIVE_KEY_PATTERN.test(key) + ? REDACTED_VALUE + : redactSensitiveText(value); + } + if (value === null || typeof value !== "object") { + return value; + } + // Error instances are passed to Pino's error serializer, which applies the + // same text redaction to messages, stacks, and causes without changing the + // original error object. + if (value instanceof Error || Buffer.isBuffer(value)) { + return value; + } + if (Array.isArray(value)) { + const existing = seen.get(value); + if (existing !== undefined) { + return existing; + } + const result: unknown[] = []; + seen.set(value, result); + for (const entry of value) { + result.push(redactSensitiveValue(entry, undefined, seen)); + } + return result; + } + let objectTag: string; + try { + objectTag = Object.prototype.toString.call(value); + } catch { + return value; + } + if (objectTag !== "[object Object]") { + return value; + } + const existing = seen.get(value); + if (existing !== undefined) { + return existing; + } + const result: Record = {}; + seen.set(value, result); + for (const [entryKey, entryValue] of Object.entries(value)) { + result[entryKey] = redactSensitiveValue(entryValue, entryKey, seen); + } + return result; +} + +export function redactSensitiveLogValue(value: unknown): unknown { + return redactSensitiveValue(value, undefined, new Map()); +} + +export function redactSensitiveLogObject( + value: Record, +): Record { + const redacted = redactSensitiveLogValue(value); + return isRecord(redacted) ? redacted : value; +} + +/** Create or repair a directory so only the current OS user can access it. */ +export function ensurePrivateDirectory(directory: string): void { + mkdirSync(directory, { + mode: PRIVATE_DIRECTORY_MODE, + recursive: true, + }); + chmodSync(directory, PRIVATE_DIRECTORY_MODE); +} diff --git a/packages/logger/test/logger.test.ts b/packages/logger/test/logger.test.ts index 0365255c6e..f14ecbb5a0 100644 --- a/packages/logger/test/logger.test.ts +++ b/packages/logger/test/logger.test.ts @@ -3,6 +3,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { + ensurePrivateDirectory, + redactSensitiveLogValue, + redactSensitiveText, +} from "../src/privacy.js"; const LOGGER_IMPORT_SPECIFIER = "@bb/logger"; @@ -182,6 +187,102 @@ afterAll(async () => { } }); +describe("logger privacy", () => { + it("redacts common credential formats while keeping diagnostic context", () => { + const credential = "sk-example-secret-value"; + const text = [ + `OPENAI_API_KEY=${credential}`, + "Authorization: Bearer bearer-example-value", + "request failed after 2 retries", + "jwt eyJhbGciOiJIUzI1NiJ9.payload.signature", + ].join("\n"); + + const redacted = redactSensitiveText(text); + + expect(redacted).toContain("OPENAI_API_KEY=[REDACTED]"); + expect(redacted).toContain("Authorization: Bearer [REDACTED]"); + expect(redacted).toContain("request failed after 2 retries"); + expect(redacted).toContain("jwt [REDACTED]"); + expect(redacted).not.toContain(credential); + expect(redacted).not.toContain("bearer-example-value"); + }); + + it("redacts credential-shaped values nested in arrays", () => { + const credential = "sk-example-array-secret"; + expect( + redactSensitiveLogValue({ + details: [{ token: credential }], + }), + ).toEqual({ + details: [{ token: "[REDACTED]" }], + }); + }); + + it("creates and repairs data and log directories with owner-only permissions", () => { + const dataDir = createTempDir(); + fs.chmodSync(dataDir, 0o755); + const logDir = path.join(dataDir, "logs"); + + ensurePrivateDirectory(logDir); + + expect(fs.statSync(dataDir).mode & 0o777).toBe(0o755); + expect(fs.statSync(logDir).mode & 0o777).toBe(0o700); + + ensurePrivateDirectory(dataDir); + expect(fs.statSync(dataDir).mode & 0o777).toBe(0o700); + }); + + it("redacts credential-shaped fields before persisting logs", async () => { + const dataDir = createTempDir(); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("BB_DATA_DIR", dataDir); + + const { createLogger } = await importFreshLogger(); + const logger = createLogger({ component: "privacy" }); + const logDir = path.join(dataDir, "logs"); + const credential = "sk-example-persisted-value"; + + logger.error( + { + stderr: `OPENAI_API_KEY=${credential}`, + token: credential, + }, + `provider failed with ${credential}`, + ); + await waitFor(() => readComponentLogLines(logDir, "privacy").length === 1); + + const serialized = JSON.stringify(readComponentLogLines(logDir, "privacy")); + expect(serialized).not.toContain(credential); + expect(serialized).toContain("[REDACTED]"); + }); + + it("redacts nested error messages and causes before persistence", async () => { + const dataDir = createTempDir(); + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("BB_DATA_DIR", dataDir); + + const { createLogger } = await importFreshLogger(); + const logger = createLogger({ component: "error-privacy" }); + const logDir = path.join(dataDir, "logs"); + const credential = "sk-example-error-secret"; + const error = new Error(`provider failed: ${credential}`, { + cause: new Error(`OPENAI_API_KEY=${credential}`), + }); + + logger.error({ err: error }, "provider error"); + await waitFor( + () => readComponentLogLines(logDir, "error-privacy").length === 1, + ); + + const serialized = JSON.stringify( + readComponentLogLines(logDir, "error-privacy"), + ); + expect(serialized).not.toContain(credential); + expect(serialized).toContain("provider failed: [REDACTED]"); + expect(serialized).toContain("OPENAI_API_KEY=[REDACTED]"); + }); +}); + describe("createLogger", () => { it("writes structured JSON to the component log file", async () => { const dataDir = createTempDir(); diff --git a/packages/process-utils/src/index.ts b/packages/process-utils/src/index.ts index ecba486b38..1897ff79eb 100644 --- a/packages/process-utils/src/index.ts +++ b/packages/process-utils/src/index.ts @@ -519,10 +519,11 @@ export function resolveContainedPath( } /** - * The one answer to "what does a bb-spawned child process inherit": the - * parent's env minus bb runtime-owned variables (`BB_*`) and `NODE_ENV`, - * optionally with the user's login-shell PATH substituted. Callers overlay - * only the child-specific bb env they intentionally expose afterward. + * The environment for user-facing child processes: the parent's env minus bb + * runtime-owned variables (`BB_*`) and `NODE_ENV`, optionally with the user's + * login-shell PATH substituted. These children intentionally retain the + * user's other environment (for example, `gh` credentials and git config). + * Plugin and provider processes must use `sanitizePluginProcessEnv` instead. */ export function sanitizeInheritedChildProcessEnv( args: SanitizeInheritedChildProcessEnvArgs, @@ -543,6 +544,93 @@ export function sanitizeInheritedChildProcessEnv( return sanitizedEnv; } +export interface SanitizePluginProcessEnvArgs { + env: NodeJS.ProcessEnv; + /** The user's login-shell PATH, when executable discovery needs it. */ + shellPath?: string; +} + +/** + * The minimal inherited environment for isolated plugin/provider processes. + * + * Credentials, runtime wiring, loader flags, proxy settings, and package + * manager configuration are intentionally absent. The process launcher must + * overlay provider/plugin-specific values explicitly after this function + * returns. The provider path selectors below are locations, not credentials; + * built-in providers use them to find their on-disk auth/config state. + */ +const MINIMAL_PLUGIN_PROCESS_ENV_KEYS: ReadonlySet = new Set([ + "PATH", + "Path", + "HOME", + "Home", + "USERPROFILE", + "TMPDIR", + "TMP", + "TEMP", + "LANG", + "LANGUAGE", + "LC_ALL", + "LC_CTYPE", + "LC_MESSAGES", + "LC_MONETARY", + "LC_NUMERIC", + "LC_TIME", + "SHELL", + "COMSPEC", + "ComSpec", + "PATHEXT", + "SYSTEMROOT", + "SystemRoot", + "USER", + "LOGNAME", + "PWD", + "TERM", + "COLORTERM", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "TZ", + "VOLTA_HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "CLAUDE_CONFIG_DIR", + "CODEX_HOME", + "PI_CODING_AGENT_DIR", + "PI_CONFIG_FILES", + "PI_PROFILE", + "OMP_PROFILE", + "OPENCODE_CONFIG_DIR", + "GROK_HOME", + "HERMES_HOME", + "GROK_CLAUDE_SKILLS_ENABLED", + "GROK_CURSOR_SKILLS_ENABLED", +]); + +/** + * Returns only the baseline needed to execute an isolated plugin/provider. + * Callers overlay explicit provider/plugin credentials and runtime values + * after this function returns; no ambient secret-shaped variable is copied. + */ +export function sanitizePluginProcessEnv( + args: SanitizePluginProcessEnvArgs, +): NodeJS.ProcessEnv { + const sanitizedEnv: NodeJS.ProcessEnv = {}; + for (const key of MINIMAL_PLUGIN_PROCESS_ENV_KEYS) { + const value = args.env[key]; + if (value !== undefined) { + sanitizedEnv[key] = value; + } + } + if (args.shellPath !== undefined) { + sanitizedEnv.PATH = args.shellPath; + if (sanitizedEnv.Path !== undefined) { + sanitizedEnv.Path = args.shellPath; + } + } + return sanitizedEnv; +} + /** * The `npm_config_*` keys that decide whether package scripts run. npm reads * `npm_config_` env as configuration above every `.npmrc` file (cli > diff --git a/packages/process-utils/test/index.test.ts b/packages/process-utils/test/index.test.ts index 3a0d826b4e..a5c784d18f 100644 --- a/packages/process-utils/test/index.test.ts +++ b/packages/process-utils/test/index.test.ts @@ -7,6 +7,7 @@ import { installSafeProcessDiagnostics, resolveContainedPath, sanitizeInheritedChildProcessEnv, + sanitizePluginProcessEnv, spawnPortableOutputProcess, spawnPortablePipedProcess, writeSafeProcessDiagnosticReport, @@ -300,6 +301,40 @@ describe("process utils", () => { expect("SKIP_ME" in sanitizedEnv).toBe(false); }); + it("builds a minimal plugin env and substitutes the login-shell PATH", () => { + const env: NodeJS.ProcessEnv = { + AWS_SECRET_ACCESS_KEY: "ambient-aws-secret", + CODEX_HOME: "/Users/test/.codex", + GITHUB_TOKEN: "ambient-github-secret", + HOME: "/Users/test", + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + NODE_OPTIONS: "--require=ambient-loader", + OPENAI_API_KEY: "ambient-openai-secret", + PATH: "/usr/bin", + SHELL: "/bin/zsh", + TMPDIR: "/tmp/test", + VOLTA_HOME: "/Users/test/.volta", + BB_SERVER_URL: "http://daemon.internal", + }; + + expect( + sanitizePluginProcessEnv({ + env, + shellPath: "/Users/test/bin:/usr/bin", + }), + ).toEqual({ + CODEX_HOME: "/Users/test/.codex", + HOME: "/Users/test", + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + PATH: "/Users/test/bin:/usr/bin", + SHELL: "/bin/zsh", + TMPDIR: "/tmp/test", + VOLTA_HOME: "/Users/test/.volta", + }); + }); + it("does not mutate the inherited env", () => { const env: NodeJS.ProcessEnv = { BB_DATA_DIR: "/tmp/bb-data", diff --git a/packages/templates/src/templates/bb-guide-customization.md b/packages/templates/src/templates/bb-guide-customization.md index 86e46c810e..43b0d81ce0 100644 --- a/packages/templates/src/templates/bb-guide-customization.md +++ b/packages/templates/src/templates/bb-guide-customization.md @@ -45,14 +45,14 @@ including binding/ports, data and the dev-app port, telemetry, inherited skill roots, and `BB_FF_*` flags. `BB_LOG_LEVEL` is also startup-only. Use `bb-app config`, not `bb-app env`, to change `BB_APP_URL`, `BB_INFERENCE`, `BB_INFERENCE_FALLBACK`, or `BB_TRANSCRIPTION` live. After a startup-only -change, run `bb-app stop && bb-app start` or restart the desktop app. Until -then, changing or unsetting `BB_SERVER_BIND_HOST` does not close a previous -`0.0.0.0` listener. - -With `--server-bind-host 0.0.0.0`, the startup listener and `app` rows show -`http://0.0.0.0:`. Health checks and the colocated daemon still connect -through loopback; this does not narrow the IPv4 wildcard listener. Containers -must also publish the port to the host. +change, run `bb-app stop && bb-app start` or restart the desktop app. The +unauthenticated server accepts only the loopback `BB_SERVER_BIND_HOST` value; +off-loopback values are refused. The Origin check is a browser CSRF boundary, +not authentication. + +For remote access, use bb connect or a private Tailscale Serve route protected +by ACLs. Containers must publish a loopback listener only through a private +access boundary. Server helper completions use `BB_INFERENCE` first, then `BB_INFERENCE_FALLBACK` after a transient timeout, rate limit, or diff --git a/packages/templates/src/templates/bb-guide-machines.md b/packages/templates/src/templates/bb-guide-machines.md index 3fee7e5c33..c1f1a5f803 100644 --- a/packages/templates/src/templates/bb-guide-machines.md +++ b/packages/templates/src/templates/bb-guide-machines.md @@ -14,21 +14,28 @@ The server listens on loopback by default. Remote execution machines need the account-gated bb connect route or a private Tailscale Serve URL; generate their installer while using that reachable server URL. -The Settings installer first uses the exact `bb-app` tarball served by that bb -server at `/install/bb-app.tgz`; only servers that do not implement the route -(HTTP 404) fall back to the npm registry. npm installs bb-app under this -machine enrollment's bb data directory, so the installer needs neither `sudo` -nor a global npm configuration. Installed launchd/systemd services pass -`--auto-update`. On a newer server protocol mismatch, the daemon downloads that -same artifact, updates its private install, and exits for the service manager to -restart. Failed attempts use a persisted exponential backoff that starts at 5 -seconds and caps at 5 minutes. A daemon never auto-downgrades to an older server -protocol. Use Settings → Machines or `bb machine retry-update` to bypass the -current backoff after a transient failure. +The Settings installer requires HTTPS for non-loopback server URLs (HTTP is +allowed only for explicit loopback local development). It installs only the exact +`bb-app` tarball served by that server at `/install/bb-app.tgz` and fails if the +route is unavailable; it never reuses PATH or falls back to the npm registry. +npm installs bb-app under this machine enrollment's bb data directory, so the +installer needs neither `sudo` nor a global npm configuration. -To opt out, remove `--auto-update` from the launchd plist or systemd user unit -and reload that service. Foreground/manual `bb-app host-daemon` runs leave it off -unless you pass `--auto-update` explicitly. +Installed launchd/systemd services leave unattended daemon auto-update off by +default. Pass `--auto-update` to the installer only after explicitly trusting +that server's unsigned package for unattended updates. On a newer server +protocol mismatch, an opted-in daemon downloads that same artifact, updates its +private install, and exits for the service manager to restart. HTTPS protects +transport but does not provide publisher signing or digest binding; keep this +opt-in disabled until that distribution contract exists unless the server and +build pipeline are trusted. Failed attempts use a persisted exponential backoff +that starts at 5 seconds and caps at 5 minutes. A daemon never auto-downgrades to +an older server protocol. Use Settings → Machines or `bb machine retry-update` +to bypass the current backoff after a transient failure. + +Existing service files containing `--auto-update` must have that flag removed +manually to opt out, then be reloaded. Foreground/manual `bb-app host-daemon` +runs leave it off unless you pass `--auto-update` explicitly. bb machine list List machines with ID, connection status, and relative last-seen time @@ -68,7 +75,8 @@ CLI counterpart of Settings → Updates and the sidebar Updates badge. `bb updates apply` covers provider CLIs only. Update bb-app itself with the printed upgrade command (`npx bb-app@latest`) or the desktop app's relaunch; -connected daemons then follow the server version automatically. +connected daemons with explicit auto-update opt-in can then follow the server +version. Machine selectors accept either an exact machine ID or an unambiguous machine name. `--host` is an alias for `--machine`. diff --git a/plans/bb-mobile-expo.md b/plans/bb-mobile-expo.md index 7eaed57f9c..f8fc91cbfb 100644 --- a/plans/bb-mobile-expo.md +++ b/plans/bb-mobile-expo.md @@ -424,10 +424,10 @@ Each fact comes from the code. Paths are relative to the repo root. Server profiles live in SecureStore, one key per profile: `{id, mode: direct|connect, serverUrl, label, handle?, credential?}`. -1. **Direct** (first): user enters `http(s)://host:port` (LAN with - `--server-bind-host 0.0.0.0`, Tailscale Serve HTTPS URL, or - `http://127.0.0.1:` in the simulator, `http://10.0.2.2:` in the - Android emulator). No auth, same trust model as the PWA today. Constraints: +1. **Direct** (first): user enters a loopback `http://127.0.0.1:` URL in + the simulator or `http://10.0.2.2:` in the Android emulator. Remote + phones use a Tailscale Serve HTTPS URL or bb connect, not direct LAN binding. + No auth applies to loopback direct mode. Constraints: iOS needs `NSLocalNetworkUsageDescription`; ATS allows raw LAN IPs / `.local` but blocks plain `http://` to FQDNs (Tailscale hosts must use Serve HTTPS); Android release builds need `usesCleartextTraffic` for @@ -881,9 +881,8 @@ green per PR. Simulator (`127.0.0.1`) and Android emulator (`10.0.2.2` / `adb reverse`). Deterministic seeds; e2e reset entry; screenshots kept as artifacts. - **Live QA**: `scripts/bb-dev-app current` + the dev-client on the simulator - against real providers; physical iPhone via a Tailscale Serve URL, a - temporary `BB_SERVER_BIND_HOST=0.0.0.0` LAN URL, or `bb connect expose -` from this thread. + against real providers; physical iPhone via a Tailscale Serve URL or `bb + connect expose ` from this thread. - **Connect QA**: stubbed apex + gate for automated flows; a staging handle for manual checks; push and universal links on a physical device. - **Contract drift**: typecheck of the typed client from `@bb/server-contract`; diff --git a/plans/bb-mobile-research/auth-connect.md b/plans/bb-mobile-research/auth-connect.md index 3fe4cb551c..84bb76d2a9 100644 --- a/plans/bb-mobile-research/auth-connect.md +++ b/plans/bb-mobile-research/auth-connect.md @@ -2,7 +2,7 @@ - `https://getbb.app` (apex) = `apps/web` (TanStack Start on CF Workers, better-auth + D1). Routes: `/api/auth/*` (better-auth: GitHub OAuth start/callback/session/sign-out; email+password only when `DEV_EMAIL_PASSWORD_AUTH=true` on `*.localhost`) — `apps/web/src/routes/api.auth.$.tsx:6-9`, `apps/web/src/server/auth.ts:17-56`, `apps/web/src/server/local-auth.ts:4-28`. Unauthenticated code-redeem endpoints: `POST /api/connect/redeem {code}` → server tunnel credential (`api.connect.redeem.tsx:5-19`, `server/api.ts:756-819`, token prefix `bbcred_`), `POST /api/connect/redeem-machine {code}` → `{credential (bbcm_…), machineId, handle, serverUrl}` (`api.connect.redeem-machine.tsx:5-27`, `server/api.ts:825-908`). Server-credential-authenticated: `POST /api/connect/machine-code` (header `x-bb-connect-machine: `) mints a 10-min `machine-pair` code (`api.connect.machine-code.tsx:9-28`, `api.ts:608-636`; TTL `packages/connect-db/src/constants.ts:128`, cap 20 machines/servers `constants.ts:125`); `POST /api/connect/revoke-machine`. Dashboard mutations are `createServerFn`s gated by `getSessionUserId()` (`server/fns.ts:27-119`, `server/current-user.server.ts:9-14`). - `https://