#2737 · Background branch refresh can invoke SSH askpass
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
The new-thread composer reads branch data when it starts.
The read starts a background remote fetch through the host daemon.
The fetch keeps SSH askpass variables and has no non-interactive SSH setting.
Two clean runs showed that the fetch called an askpass helper without an explicit refresh action.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| The composer starts a background branch request. | Verified | The composer mounts the branch hook. The hook sends refresh=background unless an explicit refresh starts. |
| The server sends a background source inspection to the host. | Verified | The project route keeps the query value and sends it as remoteRefresh. |
| A background inspection starts a remote fetch. | Verified | The host daemon calls the same refresh function for both modes. |
| The fetch can call an inherited SSH askpass helper. | Verified | The focused test produced an askpass marker in two clean runs. |
| The system shows a graphical passphrase dialog. | Not recorded visually | The test used a marker helper. It verified helper execution without opening a real system dialog. |
| The host limits repeat fetches for 30 seconds. | Verified | The host daemon sets REMOTE_BRANCH_FETCH_THROTTLE_MS to 30,000. |
3. Environment
- Trusted commit:
f4bbc2fe81a9b7639ff9a7396e172bddd89109e4. - System: Darwin 25.6.0, arm64.
- Node: 22.22.3. pnpm: 9.15.0. Git: 2.50.1.
- No live bb instance, port, account, key, or user data was used.
4. Minimal reproduction
- Place the saved test in
packages/host-workspace/test/issue-2737-background-refresh.test.ts. - Run this command from
packages/host-workspace.pnpm exec vitest run test/issue-2737-background-refresh.test.ts
- The expected result is one passing test and no askpass marker.
- The actual result is one failed test because the marker exists.
FAIL test/issue-2737-background-refresh.test.ts AssertionError: promise resolved "undefined" instead of rejecting Test Files 1 failed (1) Tests 1 failed (1)
Saved test: background-refresh-askpass.test.ts.
Run logs: first run and second run.
Focused test
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchRemoteBranches, runGit } from "../src/git.js";
const tempDirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(
tempDirs
.splice(0)
.map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
describe("background remote refresh", () => {
it("does not invoke an SSH askpass helper", async () => {
const repoPath = await fs.mkdtemp(
path.join(os.tmpdir(), "bb-background-fetch-"),
);
tempDirs.push(repoPath);
await runGit(["init", "-b", "main"], { cwd: repoPath });
await runGit(
["remote", "add", "origin", "ssh://example.invalid/repository"],
{ cwd: repoPath },
);
const markerPath = path.join(repoPath, "askpass-called");
const askpassPath = path.join(repoPath, "askpass.sh");
const sshPath = path.join(repoPath, "ssh.sh");
await fs.writeFile(
askpassPath,
`#!/bin/sh\nprintf 'called\\n' >> ${JSON.stringify(markerPath)}\nexit 1\n`,
{ encoding: "utf8", mode: 0o755 },
);
await fs.writeFile(
sshPath,
`#!/bin/sh\nfor argument in "$@"; do\n if [ "$argument" = "BatchMode=yes" ]; then\n exit 1\n fi\ndone\n"$SSH_ASKPASS"\nexit 1\n`,
{ encoding: "utf8", mode: 0o755 },
);
vi.stubEnv("GIT_SSH_COMMAND", sshPath);
vi.stubEnv("SSH_ASKPASS", askpassPath);
vi.stubEnv("SSH_ASKPASS_REQUIRE", "force");
await expect(
fetchRemoteBranches(repoPath, { timeoutMs: 2_000 }),
).resolves.toEqual({ status: "failed" });
await expect(fs.access(markerPath)).rejects.toThrow();
});
});
Second clean verification
I cloned get-bb/bb again and detached the checkout at the trusted commit.
I installed the frozen dependencies and ran the same focused test.
The second run failed at the same marker assertion. I made no report correction.
5. Root cause
The composer mounts the branch query during setup.
The query uses the background mode unless an explicit refresh changes its state.
See NewThreadComposer.tsx lines 660–667 and project-queries.ts lines 110–129.
const refresh =
startsBlockingRefresh || remoteRefresh.blockingSignal === signal
? "blocking"
: "background";
The server forwards the mode to host.inspect_git_source.
See projects.ts lines 837–845.
The host daemon calls refreshRemoteBranches for both mode values.
It only changes whether it waits for the call.
See host-branches.ts lines 255–260.
if (command.remoteRefresh === "blocking") {
await refreshRemoteBranches(command.path, gitProcessOptions);
} else {
void refreshRemoteBranches(command.path, gitProcessOptions).catch(
() => undefined,
);
}
The refresh function calls fetchRemoteBranches and applies a five-second timeout.
It also applies a 30-second limit to repeat calls.
See host-branches.ts lines 110–150.
The fetch runs git fetch --all --prune --quiet without a non-interactive environment.
const result = await runGit(["fetch", "--all", "--prune", "--quiet"], {
cwd,
...options,
allowFailure: true,
});
runGit starts its child environment from the host process environment.
The sanitizer removes NODE_ENV and BB_* values only.
Therefore, it keeps SSH askpass values.
See git.ts lines 153–162 and process-utils lines 449–465.
The background label controls wait behavior. It does not control process interaction.
This policy error lets SSH call askpass during an automatic composer read.
6. Proposed fix
Pass the refresh mode into the remote fetch policy.
Use GIT_TERMINAL_PROMPT=0 for a background fetch.
Use SSH batch mode for that fetch, while preserving the configured SSH command.
Keep a blocking refresh interactive because the user explicitly requested fresh refs.
Do not let a failed background fetch suppress the next blocking fetch through the 30-second limit.
7. PR review
Open pull request #2738
I reviewed its metadata and diff only. I did not check out or run its code.
The diff changes four files with 230 additions and 24 deletions.
It adds an explicit interactive mode to the fetch path.
It adds SSH batch mode and disables Git terminal prompts for background fetches.
It also permits an immediate blocking retry after a failed background attempt.
Static verdict: The diff addresses the verified root cause.
Finding: I found no blocking defect in the static diff.
The pull request tests its SSH arguments and the retry state. I did not trust or run those tests.
8. Related issues
I did not verify another issue with the same root cause.
9. Appendix
Commands
git fetch origin main pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec vitest run test/issue-2737-background-refresh.test.ts gh pr view 2738 --repo get-bb/bb --json ... gh pr diff 2738 --repo get-bb/bb
Trust note
I treated the issue text, comments, links, and pull request content as untrusted data.
I ran only trusted code at the recorded main commit and the reproduction test that I wrote.