← reports

#2322 · env.json writers can silently overwrite concurrent updates

Type: Bug (not set on issue) Priority: Medium Effort: not set desktop cli open on GitHub 2026-08-24 · base 494f66526

Verdict: REPRODUCED · Root-cause confidence: high

Filed by an agent (GPT-5.6) on 2026-08-23 with no comments. No linked PRs. The launcher code it points at is unchanged between the base commit and origin/main at the time of writing, so this is not already fixed.

1. TL;DR

bb-app env set KEY VALUE and bb-app env unset KEY update ~/.bb/env.json (provider secrets such as OPENAI_API_KEY and startup-only knobs such as BB_SERVER_PORT) by reading the whole file, patching it in memory, writing a temp file, and rename()-ing it over env.json. The rename is atomic, but nothing prevents two writers from both reading the same old snapshot: whichever renames last wins and silently drops the other writer's change. With two bb-app env set processes started at the same time against an empty data dir, 16 of 20 trials ended with only one of the two keys in the file; in-process, the loss is deterministic. The mirror case is worse for secrets: an env unset OPENAI_API_KEY racing with any unrelated env set leaves the supposedly removed key back in the file.

One claim in the issue does not hold: there is no plugin or server code path that writes env.json. The server only reads the file on reload, and no plugin API touches it. The only writer in the repo is the launcher's env command, so the real-world race is two bb-app env invocations at once (for example two agents, or an agent and a human). The same read-merge-rename shape is used for config.json (bb-app config set) and client.json, and config.json loses updates the same way (8 of 10 trials).

2. Claims vs findings

Claim from the issueStatusEvidence
"A BB CLI process and an authorized plugin both update different keys in the managed env.json file."Partially refutedNo plugin surface writes env.json. grep -rn formatBbAppEnvPath\|bbAppManagedEnvFileSchema over the repo finds only packages/bb-app/src/launcher.ts (read + write) and apps/server/src/services/system/bb-app-managed-config.ts (read only, L148-L160). packages/plugin-sdk, packages/plugin-host, plugins/, apps/desktop, apps/cli, and apps/host-daemon have zero references. The only in-repo writer is bb-app env set|unset; the concurrent-writer scenario that actually exists is two launcher processes.
"Each writer can read a whole document, project its change, and atomically rename that projection later."VerifiedwriteManagedEnv → readManagedEnvFile + mergeManagedEnvFile + writeManagedEnvFile (temp file + rename), launcher.ts L1254-L1283.
"The rename is atomic but not conditional at the commit boundary, so the last writer can silently erase an unrelated concurrent update."VerifiedProcess-level: 16/20 lost updates (race-20.log). In-process vitest repro fails deterministically on both the set/set and unset/set cases (vitest-repro.log). Neither writer reports an error.
"launcher.ts:1183-1211 performs whole-document merge and temp-file rename with no expected revision or shared writer lock."Verified (line numbers stale)At base 494f66526 the merge is L1169-L1184 and the write is L1262-L1283. No lock, no revision, no O_EXCL, no mtime/inode check anywhere in the path.
"launcher.ts:1905-1938 routes both env set and env unset through that read-then-write shape."Verified (line numbers stale)At base: runEnvCommand L1979-L2032. unset (L1999-L2013) calls readManagedEnvFile then writeManagedEnvFile directly; set (L2024-L2030) goes through writeManagedEnv, which does the same read → merge → write. Two code paths, same race.
Expected: "BB owns one transactional env.json mutation surface used by CLI, server, and authorized plugins."Unverified (product ask)Neither the server nor any plugin mutates env.json today, so "used by CLI, server, and plugins" describes a surface that does not exist rather than one that is broken. The defect itself only needs the launcher's two paths serialized.
Acceptance: "Plugins can configure non-secret launcher paths without editing env.json through a private file protocol."Unverified (feature request)No evidence in the repo or issue of a plugin that needs this. Out of scope for the bug; listed here so a reviewer does not treat it as part of the fix.
Acceptance: "Successful mutation preserves ... private file mode."Already trueTemp file is created with mode: 0o600; after the race the surviving file is still -rw------- (repro test line 40 passes; control run shows -rw-------).

3. Environment

4. Minimal reproduction

Repro files: 2322/repro/ — race.sh (two real processes), control-and-config.sh (sequential control + config.json variant), env-concurrent-writers.test.ts (deterministic in-process vitest).

  1. Build the launcher at the base commit.
    git checkout 494f66526
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build --filter=bb-app
  2. Run two bb-app env set processes for different keys at the same time against one fresh data dir, 20 times, and inspect env.json after each pair finishes (this is exactly what race.sh does).
    unset BB_SERVER_URL
    DATA=$(mktemp -d /tmp/bb-2322-race.XXXXXX)
    node packages/bb-app/dist/bb-app.js --data-dir "$DATA" --server-port 45711 env set OPENAI_API_KEY key-a &
    node packages/bb-app/dist/bb-app.js --data-dir "$DATA" --server-port 45711 env set BB_SERVER_PORT 40123 &
    wait; cat "$DATA/env.json"
    expected (every trial): { "env": { "OPENAI_API_KEY": "key-a", "BB_SERVER_PORT": "40123" } }
    actual (bash 2322/repro/race.sh <repo> 20):
    trial 1: ok (both keys present)
    trial 2: LOST UPDATE -> env.json has keys [BB_SERVER_PORT]
    trial 3: LOST UPDATE -> env.json has keys [BB_SERVER_PORT]
    trial 4: LOST UPDATE -> env.json has keys [OPENAI_API_KEY]
    trial 5: LOST UPDATE -> env.json has keys [BB_SERVER_PORT]
    trial 6: ok (both keys present)
    trial 7: LOST UPDATE -> env.json has keys [OPENAI_API_KEY]
    trial 8: LOST UPDATE -> env.json has keys [OPENAI_API_KEY]
    trial 9: LOST UPDATE -> env.json has keys [BB_SERVER_PORT]
    trial 10: ok (both keys present)
    trial 11: LOST UPDATE -> env.json has keys [BB_SERVER_PORT]
    trial 12: LOST UPDATE -> env.json has keys [BB_SERVER_PORT]
    trial 13: LOST UPDATE -> env.json has keys [BB_SERVER_PORT]
    trial 14: LOST UPDATE -> env.json has keys [BB_SERVER_PORT]
    trial 15: LOST UPDATE -> env.json has keys [OPENAI_API_KEY]
    trial 16: LOST UPDATE -> env.json has keys [BB_SERVER_PORT]
    trial 17: LOST UPDATE -> env.json has keys [OPENAI_API_KEY]
    trial 18: LOST UPDATE -> env.json has keys [BB_SERVER_PORT]
    trial 19: LOST UPDATE -> env.json has keys [OPENAI_API_KEY]
    trial 20: ok (both keys present)
    lost updates: 16 / 20
    Both processes print Set <KEY> in .../env.json and exit 0; nothing signals that one of the writes was discarded.
  3. Control: the same two commands run one after the other keep both keys and the private mode (control-and-config.sh, first section).
    Set OPENAI_API_KEY in /tmp/bb-2322-seq.ePZb14/env.json
    No running bb server found at http://127.0.0.1:45711; config will apply on next start.
    Set BB_SERVER_PORT in /tmp/bb-2322-seq.ePZb14/env.json
    No running bb server found at http://127.0.0.1:45711; config will apply on next start.
    --- /tmp/bb-2322-seq.ePZb14/env.json (-rw-------):
    {
      "env": {
        "OPENAI_API_KEY": "key-a",
        "BB_SERVER_PORT": "40123"
      }
    }
  4. Deterministic unit-level repro. Copy 2322/repro/env-concurrent-writers.test.ts to packages/bb-app/test/ and run it. Because both runBbApp calls suspend at the same await readFile before either renames, the interleaving is fixed and both assertions fail every time.
    cd packages/bb-app
    env -u BB_SERVER_URL pnpm exec vitest run test/env-concurrent-writers.test.ts
    FAIL  issue #2322: concurrent env.json writers > keeps both keys when two `env set` writers run concurrently
    AssertionError: expected { env: { BB_SERVER_PORT: '40123' } } to deeply equal { env: { …(2) } }
      {
        "env": {
          "BB_SERVER_PORT": "40123",
    -     "OPENAI_API_KEY": "key-from-writer-a",
        },
      }
     ❯ test/env-concurrent-writers.test.ts:42:21
    
    FAIL  issue #2322: concurrent env.json writers > does not resurrect a key that a concurrent `env unset` removed
    AssertionError: expected { env: { …(2) } } to deeply equal { env: { BB_SERVER_PORT: '40123' } }
      {
        "env": {
          "BB_SERVER_PORT": "40123",
    +     "OPENAI_API_KEY": "stale-secret",
        },
      }
     ❯ test/env-concurrent-writers.test.ts:67:21
    
     Test Files  1 failed (1)
          Tests  2 failed (2)
    The first assertion fails because the BB_SERVER_PORT writer's snapshot ({}) never saw OPENAI_API_KEY. The second fails because the set writer's snapshot still contained OPENAI_API_KEY: "stale-secret", so its rename re-committed a secret the concurrent unset had just removed. The mode assertion on line 40 passes: private mode is not the problem.

Repro test (also at 2322/repro/env-concurrent-writers.test.ts)

// Repro for get-bb/bb#2322: two concurrent `bb-app env set` writers lose one update.
// Expected to FAIL on main (494f66526): the final env.json keeps only the last writer's key.
import { mkdtempSync, readFileSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { runBbApp } from "../src/launcher.js";

// Port with nothing listening: the post-write "reload running server" POST must
// not reach the developer's real bb instance on :38886.
const UNUSED_SERVER_PORT = "45711";

// The launcher prefers BB_SERVER_URL from the environment over --server-port
// (launcher.ts resolveBbAppStartContext), so run this file with
// `env -u BB_SERVER_URL pnpm exec vitest run test/env-concurrent-writers.test.ts`.
if (process.env.BB_SERVER_URL !== undefined) {
  throw new Error(
    "Unset BB_SERVER_URL before running this repro so the post-write reload POST goes to an unused port.",
  );
}

describe("issue #2322: concurrent env.json writers", () => {
  it("keeps both keys when two `env set` writers run concurrently", async () => {
    const dataDir = mkdtempSync(join(tmpdir(), "bb-app-env-race-"));

    await Promise.all([
      runBbApp([
        "--data-dir", dataDir, "--server-port", UNUSED_SERVER_PORT,
        "env", "set", "OPENAI_API_KEY", "key-from-writer-a",
      ]),
      runBbApp([
        "--data-dir", dataDir, "--server-port", UNUSED_SERVER_PORT,
        "env", "set", "BB_SERVER_PORT", "40123",
      ]),
    ]);

    const envPath = join(dataDir, "env.json");
    const written = JSON.parse(readFileSync(envPath, "utf8"));
    // Private file mode is preserved (this part works today).
    expect(statSync(envPath).mode & 0o777).toBe(0o600);
    // BUG: only one of the two keys survives.
    expect(written).toEqual({
      env: { OPENAI_API_KEY: "key-from-writer-a", BB_SERVER_PORT: "40123" },
    });
  });

  it("does not resurrect a key that a concurrent `env unset` removed", async () => {
    const dataDir = mkdtempSync(join(tmpdir(), "bb-app-env-race-unset-"));
    await runBbApp([
      "--data-dir", dataDir, "--server-port", UNUSED_SERVER_PORT,
      "env", "set", "OPENAI_API_KEY", "stale-secret",
    ]);

    await Promise.all([
      runBbApp([
        "--data-dir", dataDir, "--server-port", UNUSED_SERVER_PORT,
        "env", "unset", "OPENAI_API_KEY",
      ]),
      runBbApp([
        "--data-dir", dataDir, "--server-port", UNUSED_SERVER_PORT,
        "env", "set", "BB_SERVER_PORT", "40123",
      ]),
    ]);

    const written = JSON.parse(readFileSync(join(dataDir, "env.json"), "utf8"));
    // BUG: the `set` writer's stale snapshot re-commits the secret that `unset` removed.
    expect(written).toEqual({ env: { BB_SERVER_PORT: "40123" } });
  });
});

5. Root cause

The launcher treats env.json as a whole-document value. Every mutation is read snapshot → compute new document → atomic replace, with no coordination between writers. Atomic replace only guarantees that readers never see a torn file; it says nothing about whether the snapshot the writer started from is still current. Two writers that both read before either renames will each produce a full document from the same stale base, and the second rename() overwrites the first writer's document wholesale. The symptom (a key vanishes, or an unset key reappears) is exactly the classic lost-update anomaly.

The set path

packages/bb-app/src/launcher.ts L1254-L1283:

async function writeManagedEnv(args: WriteManagedEnvFileArgs): Promise<void> {
  const existingConfig = await readManagedEnvFile({ dataDir: args.dataDir }); // (1) snapshot
  await writeManagedEnvFile({
    config: mergeManagedEnvFile(existingConfig, args.config),                  // (2) patch in memory
    dataDir: args.dataDir,
  });
}

async function writeManagedEnvFile(args: WriteManagedEnvFileArgs): Promise<void> {
  await mkdir(args.dataDir, { recursive: true });
  const nextConfig = pruneManagedEnvFile(args.config);
  const envPath = formatBbAppEnvPath(args.dataDir);
  const tempPath = join(args.dataDir, `.env.json.${process.pid}.${randomUUID()}.tmp`);
  try {
    await writeFile(tempPath, `${JSON.stringify(nextConfig, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
    await rename(tempPath, envPath);                                            // (3) unconditional replace
  } catch (error) {
    await unlink(tempPath).catch(() => undefined);
    throw error;
  }
}

Between (1) and (3) there is no lock, no O_EXCL guard, and no check that env.json is still the file that was read. The per-process temp name (pid + uuid) only prevents two writers from clobbering each other's temp file; it does nothing for the final rename target.

The unset path is a second copy of the same shape

launcher.ts L1999-L2013 does not even go through writeManagedEnv: it calls readManagedEnvFile and then writeManagedEnvFile with unsetManagedEnvKey(currentConfig, key). So set and unset are two separately written read-then-write sequences, which is why the issue asks for them to share one path. The merge itself (L1169-L1184) is a shallow spread over the stale currentConfig.env, which is fine on its own; it is the stale input that is the problem.

Why the process-level race hits so often

Each bb-app env set process spends ~100 ms+ booting Node and loading the 640 KB bundle before it reaches (1). Two processes started back-to-back reach (1) within microseconds of each other, and the window from (1) to (3) is a few file-system syscalls, so "both read before either writes" is the common case, not the edge case. In-process (vitest) the interleaving is fixed because both calls park on the same await readFile tick.

Deeper issue: every managed file in the launcher has the same shape

What is not involved: the server never writes these files (it only reads them on /api/v1/system/config/reload), the host daemon does not touch them, and there is no plugin API for them. The issue's framing of "CLI vs. authorized plugin" is therefore hypothetical; the real contention is launcher-vs-launcher (two agents or an agent and a human running bb-app env/bb-app config at the same moment), which is plausible now that agents routinely run these commands.

History: the env write path arrived with commit f17e8e4b6 ("Replace AI service implementation", 2026-05-18) and has not changed since. git log 494f66526..origin/main -- packages/bb-app/src/launcher.ts is empty, so the defect is still on main.

6. Proposed fix (first principles)

POSIX has no compare-and-rename, so a "conditional rename" cannot be built from rename() alone; any revision-check scheme still needs mutual exclusion between the check and the rename. All writers are local processes on one filesystem, so the simplest correct primitive is an exclusive lock file held across the whole read → patch → rename sequence:

  1. Add one helper in launcher.ts, e.g. mutateManagedJsonFile({ dataDir, fileName, mutate }), that (a) mkdir -p the data dir, (b) acquires <dataDir>/.<fileName>.lock with open(lockPath, "wx", 0o600) in a bounded retry loop (say 50 × 20 ms, then fail with "another bb-app command is updating env.json; retry"), treating a lock older than a few seconds as stale and removing it, (c) reads the current file inside the lock, (d) calls mutate(current) to get the next document, (e) does the existing temp-file + rename with mode: 0o600, and (f) releases the lock in finally.
  2. Route env set, env unset, config set, config unset (and the client writers) through that helper, deleting the separate readX-then-writeX sequences in runEnvCommand and runConfigCommand. This also satisfies the "set and unset share the same transaction path" acceptance item.
  3. Keep the existing pre-validation (validateManagedConfigForWrite, parseServerBindHost) before taking the lock, so invalid input never holds or touches the file.
  4. Test: the two in-process cases in the repro file become the regression tests (both keys survive; unset is not resurrected), plus a spawn-based case that starts two real dist/bb-app.js processes and asserts both keys are present. Passing the lock means the Promise.all interleaving now serializes; the first test's assertions flip to green without any change to the file format.

What could go wrong: a crashed writer leaves a stale lock (handled by the age check); lock files on network filesystems where O_EXCL is unreliable (not a supported bb-app setup; bb-app declares os: darwin, linux and data dirs are local); a human editing env.json in an editor is still unsynchronized, which is acceptable. A revision field inside the JSON would change the strict bbAppManagedEnvFileSchema wire/file shape for no benefit over the lock, so avoid it. No server, daemon, or protocol change is needed, so HOST_DAEMON_PROTOCOL_VERSION is untouched. The "plugins configure launcher paths via a private file protocol" acceptance item is a separate feature and should be split out of this bug.

7. PR review

No pull requests are linked to this issue (gh pr list --search 2322 returns nothing; the PRs that mention env.json — #2045, #2087, #1125 — are merged and unrelated).

8. Related issues

9. Appendix

Artifacts

Commands run

gh issue view 2322 --comments --json number,title,body,labels,state,comments,createdAt,author,url
git checkout 494f66526 && pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
git fetch origin main; git log 494f66526..origin/main --oneline -- packages/bb-app/src/launcher.ts packages/bb-app/src/   # empty
grep -rn "formatBbAppEnvPath\|bbAppManagedEnvFileSchema\|BbAppManagedEnvFile" --include='*.ts' --include='*.tsx' .   # launcher.ts + server read-only
grep -rn "managedEnv\|ManagedEnv\|env\.json\|bb-app env" packages/plugin-sdk/src packages/plugin-host/src plugins apps/desktop/src apps/cli/src apps/host-daemon/src   # no hits
git log --oneline -S "writeManagedEnvFile" -- packages/bb-app/src/launcher.ts   # f17e8e4b6 2026-05-18
lsof -nP -iTCP:45711 -sTCP:LISTEN   # free
cd packages/bb-app && env -u BB_SERVER_URL -u BB_HOST_DAEMON_PORT pnpm exec vitest run test/env-concurrent-writers.test.ts
env -u BB_SERVER_URL -u BB_HOST_DAEMON_PORT bash /tmp/bb-reports/issues/2322/repro/race.sh <worktree> 20
bash /tmp/bb-reports/issues/2322/repro/control-and-config.sh <worktree> 10
gh pr list --search "env.json" --state all; gh pr list --search 2322 --state all; gh issue list --search "env.json" --state all

Note on the first vitest run

The first execution of the repro test ran with the orchestration shell's BB_SERVER_URL=http://127.0.0.1:62937 still exported. Because the launcher prefers that variable over --server-port, the post-write reload POST /api/v1/system/config/reload reached the workflow's own dev server on :62937 (not the user's real instance on :38886), which simply re-read its own managed config. Both runs produced identical lost-update results; the logs kept in this report are from the second run with the variable unset. The scripts and test now guard against this.

Raw control-and-config.log

== control: sequential env set
Set OPENAI_API_KEY in /tmp/bb-2322-seq.ePZb14/env.json
No running bb server found at http://127.0.0.1:45711; config will apply on next start.
Set BB_SERVER_PORT in /tmp/bb-2322-seq.ePZb14/env.json
No running bb server found at http://127.0.0.1:45711; config will apply on next start.
--- /tmp/bb-2322-seq.ePZb14/env.json (-rw-------):
{
  "env": {
    "OPENAI_API_KEY": "key-a",
    "BB_SERVER_PORT": "40123"
  }
}

== config.json: concurrent config set (same read-merge-rename shape)
trial 1: LOST UPDATE -> config.json has keys [BB_APP_URL]
trial 2: LOST UPDATE -> config.json has keys [BB_APP_URL]
trial 3: LOST UPDATE -> config.json has keys [BB_APP_URL]
trial 4: LOST UPDATE -> config.json has keys [BB_APP_URL]
trial 5: LOST UPDATE -> config.json has keys [BB_INFERENCE]
trial 6: ok (both keys present)
trial 7: LOST UPDATE -> config.json has keys [BB_INFERENCE]
trial 8: LOST UPDATE -> config.json has keys [BB_APP_URL]
trial 9: LOST UPDATE -> config.json has keys [BB_INFERENCE]
trial 10: ok (both keys present)
config.json lost updates: 8 / 10