From e0249db35446de2fbc01755d3cbbc68d872b0a4f Mon Sep 17 00:00:00 2001 From: Everett Morgan Date: Wed, 26 Aug 2026 07:11:33 -0700 Subject: [PATCH 01/10] fix(security): keep privileged server loopback-only --- apps/server/src/browser-request-guard.ts | 83 +------------------ apps/server/src/routes/plugins.ts | 7 +- apps/server/src/server.ts | 18 ++-- .../skills/builtin-skills/bb-cli/SKILL.md | 20 ++--- apps/server/src/start-server.ts | 18 ++-- .../test/app/startup-diagnostics.test.ts | 28 +++---- .../test/security/api-origin-guard.test.ts | 51 +++++------- .../test/services/plugins/plugin-wire.test.ts | 23 +++-- docs/configuration.md | 35 ++++---- docs/multiple-devices.md | 23 +++-- docs/platform-support.md | 15 ++-- packages/config/src/env-vars.ts | 13 +-- packages/config/test/config.test.ts | 14 ++-- .../src/templates/bb-guide-customization.md | 16 ++-- 14 files changed, 138 insertions(+), 226 deletions(-) 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/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/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 484f0a7b80..2a6f3b3752 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 diff --git a/apps/server/src/start-server.ts b/apps/server/src/start-server.ts index 2085d021b7..92f98a41b6 100644 --- a/apps/server/src/start-server.ts +++ b/apps/server/src/start-server.ts @@ -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, @@ -216,13 +225,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/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/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-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/configuration.md b/docs/configuration.md index cd5f4e96fb..93b1b5846e 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. @@ -133,7 +134,7 @@ signal it, so a stale file left by a crash cannot stop an unrelated process. | `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_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. | @@ -872,19 +873,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/`, @@ -922,9 +923,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..5d58862765 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 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/packages/config/src/env-vars.ts b/packages/config/src/env-vars.ts index 3fc8b7b1aa..56d7b1c0f5 100644 --- a/packages/config/src/env-vars.ts +++ b/packages/config/src/env-vars.ts @@ -18,7 +18,7 @@ import { validateLogLevel } from "./log-level.js"; import { validateOptionalUrl, validateRequiredUrl } 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 { @@ -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, }); diff --git a/packages/config/test/config.test.ts b/packages/config/test/config.test.ts index 7cd1c18291..fa73910ae2 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", () => { 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 From 02ef0693463b15d71d160911aa15692632cf6c99 Mon Sep 17 00:00:00 2001 From: Everett Morgan Date: Wed, 26 Aug 2026 07:13:57 -0700 Subject: [PATCH 02/10] fix: isolate plugin and provider process environments --- .../src/plugin-host-manager.test.ts | 42 +++++++- apps/host-daemon/src/plugin-host-manager.ts | 10 +- docs/api_to_audit.md | 6 +- .../src/runtime-provider-process.ts | 7 +- .../src/runtime.process-lifecycle.test.ts | 11 ++- packages/process-utils/src/index.ts | 96 ++++++++++++++++++- packages/process-utils/test/index.test.ts | 35 +++++++ 7 files changed, 188 insertions(+), 19 deletions(-) diff --git a/apps/host-daemon/src/plugin-host-manager.test.ts b/apps/host-daemon/src/plugin-host-manager.test.ts index b99d8c8557..4793b614ec 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,6 +31,7 @@ export default { experimental_apiVersion: 1, contract: { echo: { input: anySchema, output: anySchema }, + env: { input: anySchema, output: anySchema }, wait: { input: anySchema, output: anySchema }, crash: { input: anySchema, output: anySchema }, stringEcho: { input: stringSchema, output: stringSchema }, @@ -44,6 +45,15 @@ 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 }); @@ -129,6 +139,7 @@ describe("PluginHostManager", () => { .splice(0) .map((dir) => rm(dir, { recursive: true, force: true })), ); + vi.unstubAllEnvs(); }); async function createManager( @@ -631,16 +642,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 +682,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..464d2b8c9e 100644 --- a/apps/host-daemon/src/plugin-host-manager.ts +++ b/apps/host-daemon/src/plugin-host-manager.ts @@ -14,7 +14,7 @@ import { jsonValueSchema, type JsonValue } from "@bb/domain"; import { createPluginProcessTempDir, ensurePluginProcessDataDir, - sanitizeInheritedChildProcessEnv, + sanitizePluginProcessEnv, } from "@bb/process-utils"; import type { HostDaemonLogger } from "./logger.js"; import { ensureCachedPluginHostArtifact } from "./plugin-host-artifact-cache.js"; @@ -465,9 +465,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 } : {}), }), 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/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/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", From 61a49c503da477192b6decdc500dd337ba04ed03 Mon Sep 17 00:00:00 2001 From: Everett Morgan Date: Wed, 26 Aug 2026 07:20:59 -0700 Subject: [PATCH 03/10] fix: harden machine updater bootstrap --- .../src/protocol-self-update.test.ts | 37 ++-- apps/host-daemon/src/protocol-self-update.ts | 14 +- apps/host-daemon/src/server-client.test.ts | 21 +++ apps/host-daemon/src/server-client.ts | 8 +- apps/server/src/assets/install-machine.sh | 80 ++++++--- .../skills/builtin-skills/bb-cli/SKILL.md | 26 ++- .../test/app/install-machine-script.test.ts | 163 +++++++++++------- docs/configuration.md | 26 +-- docs/multiple-devices.md | 52 +++--- .../src/templates/bb-guide-machines.md | 38 ++-- 10 files changed, 299 insertions(+), 166 deletions(-) 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/server-client.test.ts b/apps/host-daemon/src/server-client.test.ts index e4647ee52b..eaf4a63ef9 100644 --- a/apps/host-daemon/src/server-client.test.ts +++ b/apps/host-daemon/src/server-client.test.ts @@ -184,6 +184,27 @@ describe("createServerClient", () => { expect(fetchFn).not.toHaveBeenCalled(); }); + it("refuses non-HTTP loopback server URLs", async () => { + const fetchFn = vi.fn(); + const client = createServerClient({ + fetchFn, + getSessionId: () => "session-1", + hostKey: "host-key", + logger: createLogger(), + serverUrl: "ftp://127.0.0.1", + }); + + await expect( + client.fetchProjectAttachment({ + maxBytes: 25, + projectId: "project-1", + threadId: "thread-1", + path: "network-tab.har", + }), + ).rejects.toBeInstanceOf(AbortError); + expect(fetchFn).not.toHaveBeenCalled(); + }); + it("fetches project attachment bytes over HTTPS", async () => { const fetchFn = vi.fn(async (input) => { const url = new URL(String(input)); diff --git a/apps/host-daemon/src/server-client.ts b/apps/host-daemon/src/server-client.ts index 4382c02bfc..9ce67280bd 100644 --- a/apps/host-daemon/src/server-client.ts +++ b/apps/host-daemon/src/server-client.ts @@ -219,9 +219,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]") ); } diff --git a/apps/server/src/assets/install-machine.sh b/apps/server/src/assets/install-machine.sh index 251fd1a380..5c43f312ce 100755 --- a/apps/server/src/assets/install-machine.sh +++ b/apps/server/src/assets/install-machine.sh @@ -4,10 +4,12 @@ set -eu 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 +19,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 +101,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,6 +175,30 @@ 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. @@ -369,9 +400,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 +414,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 +435,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 +479,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 +551,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 +567,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 +657,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 +719,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/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 2a6f3b3752..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 @@ -246,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/test/app/install-machine-script.test.ts b/apps/server/test/app/install-machine-script.test.ts index dedab1f227..b68d1d3846 100644 --- a/apps/server/test/app/install-machine-script.test.ts +++ b/apps/server/test/app/install-machine-script.test.ts @@ -169,6 +169,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 +191,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 +214,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 +271,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 +315,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 +330,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", @@ -388,7 +415,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 +432,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 +461,22 @@ 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, + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "no PATH or npm fallback is permitted", ); - const daemonPid = Number( - readFileSync(join(fixture.dataDir, "install-daemon.pid"), "utf8"), + expect(existsSync(join(fixture.dataDir, "npm.log"))).toBe(false); + expect(existsSync(join(fixture.dataDir, "install-daemon.pid"))).toBe( + false, ); - process.kill(daemonPid, "SIGTERM"); }); it("fails loudly when npm skipped the native add-on install scripts", () => { @@ -494,8 +522,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 +555,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 +590,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 +599,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 +632,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 +654,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 +675,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 +698,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 +723,7 @@ fi "host-test", "--server", "https://machine.getbb.app", + "--auto-update", ], fixture, ); @@ -784,7 +817,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/docs/configuration.md b/docs/configuration.md index 93b1b5846e..f2b6d7b6c0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -522,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 diff --git a/docs/multiple-devices.md b/docs/multiple-devices.md index 5d58862765..cc84d580cf 100644 --- a/docs/multiple-devices.md +++ b/docs/multiple-devices.md @@ -139,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 @@ -159,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/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`. From 5d370f1b92082f86ffad31f925d9399fc673f669 Mon Sep 17 00:00:00 2001 From: Everett Morgan Date: Wed, 26 Aug 2026 07:48:10 -0700 Subject: [PATCH 04/10] fix(security): harden local logs and provider diagnostics --- apps/host-daemon/src/app.test.ts | 14 +- apps/host-daemon/src/app.ts | 8 +- apps/host-daemon/src/command-router.ts | 5 +- apps/host-daemon/src/index.ts | 2 + apps/host-daemon/src/injected-skills.ts | 6 +- apps/host-daemon/src/lock.test.ts | 9 ++ apps/host-daemon/src/lock.ts | 3 +- .../src/plugin-host-manager.test.ts | 31 ++++ apps/host-daemon/src/plugin-host-manager.ts | 17 +- .../src/provider-installation.test.ts | 32 ++++ apps/host-daemon/src/provider-installation.ts | 9 +- apps/host-daemon/src/runtime-manager.test.ts | 25 +-- apps/host-daemon/src/runtime-manager.ts | 30 +++- .../src/thread-storage-root.test.ts | 1 + apps/host-daemon/src/thread-storage-root.ts | 3 +- apps/server/src/index.ts | 2 + .../src/services/plugins/install-sources.ts | 3 +- .../server/src/services/plugins/plugin-api.ts | 5 +- .../server/src/services/plugins/plugin-log.ts | 21 ++- .../src/services/plugins/plugin-runtime.ts | 22 +-- .../src/services/plugins/plugin-service.ts | 8 +- .../src/services/projects/attachments.ts | 15 +- apps/server/src/start-server.ts | 3 +- .../test/services/plugins/plugin-log.test.ts | 43 +++++ docs/configuration.md | 10 ++ packages/logger/src/index.ts | 38 ++++- packages/logger/src/privacy.ts | 147 ++++++++++++++++++ packages/logger/test/logger.test.ts | 86 ++++++++++ 28 files changed, 531 insertions(+), 67 deletions(-) create mode 100644 apps/server/test/services/plugins/plugin-log.test.ts create mode 100644 packages/logger/src/privacy.ts 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 4793b614ec..793c197ad1 100644 --- a/apps/host-daemon/src/plugin-host-manager.test.ts +++ b/apps/host-daemon/src/plugin-host-manager.test.ts @@ -33,6 +33,7 @@ export default { 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 }, @@ -59,6 +60,7 @@ export default { 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 }; }, @@ -249,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( diff --git a/apps/host-daemon/src/plugin-host-manager.ts b/apps/host-daemon/src/plugin-host-manager.ts index 464d2b8c9e..6c6fcbd0bc 100644 --- a/apps/host-daemon/src/plugin-host-manager.ts +++ b/apps/host-daemon/src/plugin-host-manager.ts @@ -16,6 +16,7 @@ import { ensurePluginProcessDataDir, 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) => { @@ -600,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 ( @@ -799,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( @@ -948,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/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/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/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/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..0b8ccfe53a 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,7 +38,8 @@ 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) { @@ -40,8 +48,13 @@ export function appendPluginLogLine( } catch { // Missing file: nothing to rotate. } - const line = JSON.stringify({ ts: Date.now(), level, message }); + const line = JSON.stringify({ + ts: Date.now(), + level, + message: redactSensitiveText(message), + }); appendFileSync(file, `${line}\n`, "utf8"); + chmodSync(file, 0o600); } catch { // Best effort only. } @@ -69,5 +82,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/start-server.ts b/apps/server/src/start-server.ts index 92f98a41b6..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"; @@ -57,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, 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/docs/configuration.md b/docs/configuration.md index f2b6d7b6c0..457a15ca62 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -903,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 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..f82a072fb4 --- /dev/null +++ b/packages/logger/src/privacy.ts @@ -0,0 +1,147 @@ +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; + } + 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; + } + if (Array.isArray(value)) { + const result: unknown[] = []; + seen.set(value, result); + for (const entry of value) { + result.push(redactSensitiveValue(entry, undefined, seen)); + } + return result; + } + 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..11081a068b 100644 --- a/packages/logger/test/logger.test.ts +++ b/packages/logger/test/logger.test.ts @@ -3,6 +3,7 @@ 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, redactSensitiveText } from "../src/privacy.js"; const LOGGER_IMPORT_SPECIFIER = "@bb/logger"; @@ -182,6 +183,91 @@ 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("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(); From 651b5546de7943835d048db4fc371b1d6b1787be Mon Sep 17 00:00:00 2001 From: Everett Morgan Date: Wed, 26 Aug 2026 07:14:03 -0700 Subject: [PATCH 05/10] chore: harden release supply chain --- .github/workflows/build-desktop.yml | 54 ++++- .github/workflows/check-release-hardening.mjs | 190 ++++++++++++++++++ .github/workflows/ci.yml | 3 + .github/workflows/deploy-connect.yml | 6 + .github/workflows/deploy-demo-server.yml | 6 + .github/workflows/deploy-web.yml | 6 + .github/workflows/mobile-ios-eas.yml | 8 + .github/workflows/publish-bb-app.yml | 85 +++++--- apps/desktop/README.md | 11 +- docs/bb-release-process.md | 12 ++ package.json | 5 +- pnpm-lock.yaml | 75 ++----- 12 files changed, 364 insertions(+), 97 deletions(-) create mode 100644 .github/workflows/check-release-hardening.mjs diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 7bd2069e44..1328259759 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 @@ -207,8 +226,19 @@ jobs: 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 +294,16 @@ jobs: name: bb-desktop-linux-x64 path: release/linux + - name: Attest desktop release binaries + if: steps.release_plan.outputs.should_publish == 'true' + 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 stable desktop release feed if: steps.release_plan.outputs.should_publish == 'true' env: @@ -360,6 +400,7 @@ jobs: - name: Summarize env: + ATTESTATION_URL: ${{ steps.attest.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 +417,5 @@ 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 "- binary provenance attestation: ${ATTESTATION_URL}" } >> "$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..b7011205ba --- /dev/null +++ b/.github/workflows/check-release-hardening.mjs @@ -0,0 +1,190 @@ +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( + 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", +); + +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/apps/desktop/README.md b/apps/desktop/README.md index 2dac62382a..9051ebd006 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -121,10 +121,13 @@ Linux gets both update paths, but they are not equivalent: 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. +that ships beside it. The release publisher also creates a GitHub Artifact +Attestation for the 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. 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. ## Releasing diff --git a/docs/bb-release-process.md b/docs/bb-release-process.md index 4a5eb4352f..728e809844 100644 --- a/docs/bb-release-process.md +++ b/docs/bb-release-process.md @@ -61,6 +61,10 @@ 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`. ## Inputs @@ -226,6 +230,14 @@ 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 + still relies on the feed's SHA-512 metadata. 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/package.json b/package.json index 1e72357820..9fe2994ba2 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,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/pnpm-lock.yaml b/pnpm-lock.yaml index 2a55e52358..94824f5631 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,9 @@ settings: overrides: zod: 4.3.6 '@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: '@pierre/diffs@1.2.9': @@ -3547,7 +3550,7 @@ importers: version: typescript@7.0.2 vitest: specifier: ^4.1.1 - version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) plugins/provider-retry: dependencies: @@ -8997,9 +9000,8 @@ packages: resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} hasBin: true - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - deprecated: Potential CWE-502 - Update to 1.3.1 or higher + '@ungap/structured-clone@1.3.4': + resolution: {integrity: sha512-JL+CF0GeLHyPWI0rXu7UnxgiuOm9UQWzadi0OYOJNhNO2q6EZElpwlgXkNkfU1PzANDHq3YcwKVZprdvS+BrbQ==} '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -9130,15 +9132,13 @@ packages: '@vue/shared@3.5.39': resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} - '@xmldom/xmldom@0.8.13': - resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} engines: {node: '>=10.0.0'} - deprecated: this version has critical issues, please update to the latest version - '@xmldom/xmldom@0.9.10': - resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + '@xmldom/xmldom@0.9.12': + resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==} engines: {node: '>=14.6'} - deprecated: this version has critical issues, please update to the latest version '@xterm/addon-fit@0.12.0-beta.292': resolution: {integrity: sha512-SIuWR0KM2IpmumVqL4L43aOUeTEUeN82ItyrUFHGFtfj3veoAIbPapxJPj7O0tYngfiiHZqqm1+XsZp/Fc8LLA==} @@ -17636,19 +17636,19 @@ snapshots: '@expo/plist@0.3.5': dependencies: - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 base64-js: 1.5.1 xmlbuilder: 15.1.1 '@expo/plist@0.5.4': dependencies: - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 base64-js: 1.5.1 xmlbuilder: 15.1.1 '@expo/plist@0.8.1': dependencies: - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 base64-js: 1.5.1 xmlbuilder: 15.1.1 @@ -19491,9 +19491,7 @@ snapshots: metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.79.6': {} @@ -21121,7 +21119,7 @@ snapshots: dependencies: '@typescript/old': typescript@6.0.3 - '@ungap/structured-clone@1.3.0': {} + '@ungap/structured-clone@1.3.4': {} '@upsetjs/venn.js@2.0.0': optionalDependencies: @@ -21376,9 +21374,9 @@ snapshots: '@vue/shared@3.5.39': optional: true - '@xmldom/xmldom@0.8.13': {} + '@xmldom/xmldom@0.8.15': {} - '@xmldom/xmldom@0.9.10': {} + '@xmldom/xmldom@0.9.12': {} '@xterm/addon-fit@0.12.0-beta.292(@xterm/xterm@6.1.0-beta.292)': dependencies: @@ -23591,7 +23589,7 @@ snapshots: '@expo/log-box': 57.0.3(@expo/dom-webview@57.0.1)(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4) '@expo/metro': 56.0.0 '@expo/metro-config': 57.0.8(@typescript/typescript6@6.0.2)(expo@57.0.14) - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.3.4 babel-preset-expo: 57.0.7(@babel/core@7.29.0)(@babel/runtime@7.29.7)(expo@57.0.14)(react-refresh@0.14.2) expo-asset: 57.0.12(@typescript/typescript6@6.0.2)(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4))(react@19.2.4) expo-constants: 57.0.12(expo@57.0.14)(react-native@0.86.2(@babel/core@7.29.0)(@react-native/metro-config@0.86.2(@babel/core@7.29.0))(@types/react@19.2.13)(react@19.2.4)) @@ -24163,7 +24161,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/unist': 3.0.3 - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.3.4 hast-util-from-parse5: 8.0.3 hast-util-to-parse5: 8.0.1 html-void-elements: 3.0.0 @@ -24178,7 +24176,7 @@ snapshots: hast-util-sanitize@5.0.2: dependencies: '@types/hast': 3.0.4 - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.3.4 unist-util-position: 5.0.0 hast-util-select@6.0.4: @@ -25230,7 +25228,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.3.4 devlop: 1.1.0 micromark-util-sanitize-uri: 2.0.1 trim-lines: 3.0.1 @@ -26536,13 +26534,13 @@ snapshots: plist@3.1.0: dependencies: - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 base64-js: 1.5.1 xmlbuilder: 15.1.1 plist@3.1.1: dependencies: - '@xmldom/xmldom': 0.9.10 + '@xmldom/xmldom': 0.9.12 base64-js: 1.5.1 xmlbuilder: 15.1.1 @@ -28779,35 +28777,6 @@ snapshots: - tsx - yaml - vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.1 - '@vitest/mocker': 4.1.1(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.1 - '@vitest/runner': 4.1.1 - '@vitest/snapshot': 4.1.1 - '@vitest/spy': 4.1.1 - '@vitest/utils': 4.1.1 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.1 - vite: 8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/node': 22.19.10 - jsdom: 29.0.1(@noble/hashes@2.0.1) - transitivePeerDependencies: - - msw - vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.19.12)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.1 From 3aca28dabbb4f91d90f48916f8f5eecd71b86ba6 Mon Sep 17 00:00:00 2001 From: Everett Morgan Date: Wed, 26 Aug 2026 08:24:13 -0700 Subject: [PATCH 06/10] fix(security): complete hardening integration --- .github/workflows/build-desktop.yml | 21 +++++-- .github/workflows/check-release-hardening.mjs | 19 +++++++ README.md | 14 +---- apps/mobile/README.md | 4 +- apps/server/src/assets/install-machine.sh | 6 ++ .../server/src/services/plugins/plugin-log.ts | 9 ++- .../test/app/install-machine-script.test.ts | 3 + package.json | 1 - packages/bb-app/README.md | 5 +- packages/bb-app/src/launcher.ts | 14 +++-- packages/bb-app/test/index.test.ts | 47 +++++++--------- packages/logger/src/privacy.ts | 20 ++++--- packages/logger/test/logger.test.ts | 17 +++++- plans/bb-mobile-expo.md | 13 ++--- plans/bb-mobile-research/auth-connect.md | 6 +- plans/bb-mobile-research/testing-dev-infra.md | 14 ++--- tests/integration/helpers/harness.ts | 16 ++---- tests/integration/mobile-e2e/backend.ts | 56 ++----------------- 18 files changed, 142 insertions(+), 143 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 1328259759..877a703821 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -222,6 +222,7 @@ jobs: needs: - macos - linux + if: ${{ inputs.publish == true && inputs.release_channel == 'stable' }} runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 20 permissions: @@ -294,15 +295,23 @@ jobs: name: bb-desktop-linux-x64 path: release/linux - - name: Attest desktop release binaries + - name: Attest Linux AppImage provenance if: steps.release_plan.outputs.should_publish == 'true' - id: attest + 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 - release/linux/*.AppImage - name: Publish stable desktop release feed if: steps.release_plan.outputs.should_publish == 'true' @@ -400,7 +409,8 @@ jobs: - name: Summarize env: - ATTESTATION_URL: ${{ steps.attest.outputs.attestation-url }} + 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 }} @@ -417,5 +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 "- binary provenance attestation: ${ATTESTATION_URL}" + 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 index b7011205ba..ef937d5c26 100644 --- a/.github/workflows/check-release-hardening.mjs +++ b/.github/workflows/check-release-hardening.mjs @@ -58,6 +58,12 @@ for (const jobName of ["macos", "linux", "publish"]) { "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 || '' }}", @@ -74,6 +80,19 @@ 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", 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/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 5c43f312ce..e4a8b59484 100755 --- a/apps/server/src/assets/install-machine.sh +++ b/apps/server/src/assets/install-machine.sh @@ -2,6 +2,10 @@ 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 ] [--auto-update] @@ -205,6 +209,7 @@ curl_allowed_redirect_protocols=$curl_allowed_protocols 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])); @@ -221,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 ' diff --git a/apps/server/src/services/plugins/plugin-log.ts b/apps/server/src/services/plugins/plugin-log.ts index 0b8ccfe53a..f0bff4bd53 100644 --- a/apps/server/src/services/plugins/plugin-log.ts +++ b/apps/server/src/services/plugins/plugin-log.ts @@ -43,7 +43,9 @@ export function appendPluginLogLine( 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. @@ -53,7 +55,10 @@ export function appendPluginLogLine( level, message: redactSensitiveText(message), }); - appendFileSync(file, `${line}\n`, "utf8"); + appendFileSync(file, `${line}\n`, { + encoding: "utf8", + mode: 0o600, + }); chmodSync(file, 0o600); } catch { // Best effort only. diff --git a/apps/server/test/app/install-machine-script.test.ts b/apps/server/test/app/install-machine-script.test.ts index b68d1d3846..3f8e57b6ef 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"; @@ -402,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", diff --git a/package.json b/package.json index 9fe2994ba2..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", diff --git a/packages/bb-app/README.md b/packages/bb-app/README.md index 051dcc361a..ee6abe51bc 100644 --- a/packages/bb-app/README.md +++ b/packages/bb-app/README.md @@ -198,9 +198,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..c82eaa262b 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, @@ -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( @@ -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..052bf111ea 100644 --- a/packages/bb-app/test/index.test.ts +++ b/packages/bb-app/test/index.test.ts @@ -1,5 +1,6 @@ import { execFileSync, spawn } from "node:child_process"; import { + chmodSync, mkdirSync, mkdtempSync, readdirSync, @@ -33,7 +34,6 @@ import { resolveDataDir, resolveBbAppStartContext, resolveBbAppCommand, - resolveServerListenerUrl, resolveWorktreeRuntimePolicy, runBbApp, runBundledCliCommand, @@ -765,26 +765,17 @@ 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 +831,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 +1001,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 +1076,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 +1129,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 () => { @@ -1468,7 +1461,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 +1667,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 +1693,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 +1743,8 @@ describe("bb-app launcher", () => { String(unavailablePort), "env", "set", - "BB_SERVER_BIND_HOST", - "0.0.0.0", + "BB_SERVER_PORT", + "48886", ]), ); @@ -1771,7 +1764,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/logger/src/privacy.ts b/packages/logger/src/privacy.ts index f82a072fb4..f548ee621c 100644 --- a/packages/logger/src/privacy.ts +++ b/packages/logger/src/privacy.ts @@ -97,6 +97,18 @@ function redactSensitiveValue( 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); @@ -110,14 +122,6 @@ function redactSensitiveValue( if (existing !== undefined) { return existing; } - if (Array.isArray(value)) { - const result: unknown[] = []; - seen.set(value, result); - for (const entry of value) { - result.push(redactSensitiveValue(entry, undefined, seen)); - } - return result; - } const result: Record = {}; seen.set(value, result); for (const [entryKey, entryValue] of Object.entries(value)) { diff --git a/packages/logger/test/logger.test.ts b/packages/logger/test/logger.test.ts index 11081a068b..f14ecbb5a0 100644 --- a/packages/logger/test/logger.test.ts +++ b/packages/logger/test/logger.test.ts @@ -3,7 +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, redactSensitiveText } from "../src/privacy.js"; +import { + ensurePrivateDirectory, + redactSensitiveLogValue, + redactSensitiveText, +} from "../src/privacy.js"; const LOGGER_IMPORT_SPECIFIER = "@bb/logger"; @@ -203,6 +207,17 @@ describe("logger privacy", () => { 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); 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://