Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/cli/src/__tests__/plugin-cli-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,46 @@ describe("runPluginCliCommand", () => {
]);
});

it("materializes an API key from stdin only in the proxied request", async () => {
const requests: string[][] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_url, init: RequestInit | undefined) => {
const parsed = JSON.parse(String(init?.body)) as { argv: string[] };
requests.push(parsed.argv);
return new Response(JSON.stringify({ exitCode: 0 }), { status: 200 });
}),
);
const writes: string[] = [];
const output = {
write(value: string, callback: (error?: Error | null) => void) {
writes.push(value);
callback();
return true;
},
};
const input = {
isTTY: false,
async *[Symbol.asyncIterator]() {
yield Buffer.from("sk-from-stdin\n");
},
};

await expect(
runPluginCliCommand(
"http://localhost",
"account-pool",
["account", "add", "--provider", "claude", "--api-key-stdin"],
{ stdout: output, stderr: output },
input,
),
).resolves.toBe(0);
expect(requests).toEqual([
["account", "add", "--provider", "claude", "--api-key", "sk-from-stdin"],
]);
expect(writes).toEqual([]);
});

it("outlives the global fetch headers timeout while a plugin command waits on a human", async () => {
const RESPONSE_DELAY_MS = 1500;
const server: Server = createServer((request, response) => {
Expand Down
59 changes: 58 additions & 1 deletion apps/cli/src/plugin-cli-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,52 @@ interface PluginCliOutputStreams {
stderr: PluginCliOutputStream;
}

interface PluginCliInputStream extends AsyncIterable<Buffer | string> {
isTTY?: boolean;
}

const PLUGIN_CLI_SECRET_STDIN_MAX_BYTES = 16 * 1024;

async function materializeApiKeyStdin(
argv: readonly string[],
input: PluginCliInputStream,
): Promise<string[]> {
const indexes = argv.flatMap((arg, index) =>
arg === "--api-key-stdin" ? [index] : [],
);
if (indexes.length === 0) return [...argv];
if (indexes.length > 1 || argv.includes("--api-key")) {
throw new Error("Choose only one API-key input flag.");
}
if (input.isTTY === true) {
throw new Error("--api-key-stdin requires an API key piped on stdin.");
}
const chunks: Buffer[] = [];
let bytes = 0;
for await (const chunk of input) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
bytes += buffer.byteLength;
if (bytes > PLUGIN_CLI_SECRET_STDIN_MAX_BYTES) {
throw new Error("API key from stdin exceeds 16 KiB.");
}
chunks.push(buffer);
}
const apiKey = Buffer.concat(chunks)
.toString("utf8")
.replace(/[\r\n]+$/u, "");
if (apiKey.length === 0 || /[\r\n]/u.test(apiKey)) {
throw new Error("--api-key-stdin requires exactly one non-empty API key.");
}
const index = indexes[0];
if (index === undefined) return [...argv];
return [
...argv.slice(0, index),
"--api-key",
apiKey,
...argv.slice(index + 1),
];
}

async function writePluginCliOutput(
stream: PluginCliOutputStream,
value: string,
Expand Down Expand Up @@ -310,7 +356,18 @@ export async function runPluginCliCommand(
stdout: process.stdout,
stderr: process.stderr,
},
input: PluginCliInputStream = process.stdin,
): Promise<number> {
let resolvedArgv: string[];
try {
resolvedArgv = await materializeApiKeyStdin(argv, input);
} catch (error) {
await writePluginCliOutput(
streams.stderr,
error instanceof Error ? error.message : String(error),
);
return 1;
}
const threadId = resolveContextThreadId();
const projectId = resolveContextProjectId();
const response = await cliFetch(
Expand All @@ -319,7 +376,7 @@ export async function runPluginCliCommand(
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
argv,
argv: resolvedArgv,
cwd: process.cwd(),
...(threadId ? { threadId } : {}),
...(projectId ? { projectId } : {}),
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/services/plugins/builtin-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export const BUILTIN_PLUGINS_DIRECTORY_NAME = "builtin-plugins";
const REPO_PLUGINS_DIRECTORY_NAME = "plugins";

export const BUILTIN_PLUGINS = [
{
name: "account-pool",
pluginId: "account-pool",
defaultEnabled: false,
},
{
name: "ask-user-question",
pluginId: "ask-user-question",
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ BB_HOST_DAEMON_PORT only for an intentional non-default target.
- Prefer non-interactive commands and machine-readable output for automation.
- Pass `--yes` for a confirmed destructive command in a non-interactive shell.
- Treat plugin commands as normal top-level commands after installation.

The builtin Account Pool plugin is disabled by default. Enable it, add Claude
credentials, and inspect its proxy route and account quota with:

```sh
bb plugin enable account-pool
bb pool account add --provider claude --import
printf '%s\n' "$ANTHROPIC_API_KEY" | bb pool account add --provider claude --api-key-stdin [--label <text>] [--priority <n>]
bb pool account add --provider claude --api-key <key> [--label <text>] [--priority <n>]
bb pool account list [--json]
bb pool account remove <id>
bb pool account enable <id>
bb pool account disable <id>
bb pool status [--json] [--show-key]
```

Newly added or enabled accounts are available without a plugin reload. The hub
bearer key appears only with `status --show-key`. Agents should pipe API keys
to `--api-key-stdin`; `--api-key <key>` is an unsafe compatibility form that
exposes the key in process arguments, shell history, and agent transcripts.
Prefer `--import` for an existing Claude Code login. JSON account status
reports rejected upstream bucket resets under `bucketExhaustion`; this
diagnostic field does not affect account selection.

- Inspect real status, logs, API results, or diffs instead of assumptions.
- Keep file paths on the machine that owns the selected workspace.

Expand Down
1 change: 1 addition & 0 deletions apps/server/test/services/plugins/builtin-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ describe("builtin plugin reconciliation", () => {

it("gives every builtin plugin a deliberate settings icon", async () => {
const expectedIcons = new Map([
["account-pool", "Layers"],
["ask-user-question", "MessageQuestion"],
["automations", "Clock"],
["concurrency-limit", "Limitation"],
Expand Down
38 changes: 38 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,44 @@ how many connected clients received the broadcast. `spotlight` focuses the
target pane and persistently dims the others; `clear-spotlight` focuses it and
persistently restores undimmed splits.

## Account Pool

The builtin Account Pool plugin is disabled on fresh installations. It stores
non-secret Claude account metadata in plugin KV, quota observations in the
plugin SQLite database, and each account token plus the generated hub bearer
key in 0600 files under `<data-dir>/plugins/account-pool/secrets/accounts/`.
Enable it and add at least one account:

```sh
bb plugin enable account-pool
bb pool account add --provider claude --import
printf '%s\n' "$ANTHROPIC_API_KEY" | bb pool account add --provider claude --api-key-stdin [--label <text>] [--priority <n>]
```

The import path reads the Claude Code login on the bb server host.
`--api-key-stdin` reads exactly one non-empty key from piped standard input and
is the default API-key path for agents. The compatibility form `--api-key
<key>` remains available, but exposes the secret in process arguments, shell
history, and agent transcripts. The hub starts immediately, so a newly added
or enabled account is available without a plugin reload.

`bb pool status --show-key` is the only command that reveals the hub bearer
key. Point a client at the route printed by that command and supply the key as
`Authorization: Bearer <key>`. Account listing, enable, disable, and removal
are available through `bb pool account list|enable|disable|remove`.
JSON account status includes rejected upstream bucket resets under
`bucketExhaustion`. The field is diagnostic and does not affect selection.

Two settings control routing. `switchThreshold` is the 5-hour or 7-day quota
fraction at which an account stops receiving traffic and defaults to `0.98`.
`upstreamBaseUrl` defaults to `https://api.anthropic.com` and exists only for
tests and QA with a controlled fake upstream:

```sh
bb plugin config account-pool set switchThreshold 0.98
bb plugin config account-pool set upstreamBaseUrl http://127.0.0.1:9000
```

## bb connect

`bb connect --code <code> --server https://<handle>.getbb.app` pairs this bb
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-api-map/src/plugin-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface FirstPartyPlugin {
}

const FIRST_PARTY_PLUGINS: Record<string, FirstPartyPlugin> = {
"Account Pool": { id: "account-pool", icon: Layers01Icon },
"Ask User Question": { id: "ask-user-question", icon: MessageQuestionIcon },
Automations: { id: "automations", icon: Clock01Icon },
"Custom instructions": { id: "custom-instructions", icon: Edit04Icon },
Expand Down
29 changes: 29 additions & 0 deletions packages/templates/src/templates/bb-guide-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,35 @@ The builtin Custom instructions plugin adds a multiline editor under Settings
→ Custom instructions. Saved text is persisted on this bb host and included in
agent task instructions; blank text contributes nothing.

The builtin Account Pool plugin is disabled on fresh installations. It stores
Claude account tokens in per-account 0600 secret files and proxies Anthropic
Messages API requests through the bb server. Enable it, add an account, then
point Claude Code at the route and bearer key shown by `status`:

```
bb plugin enable account-pool
bb pool account add --provider claude --import
printf '%s\n' "$ANTHROPIC_API_KEY" | bb pool account add --provider claude --api-key-stdin [--label <text>] [--priority <n>]
bb pool account add --provider claude --api-key <key> [--label <text>] [--priority <n>]
bb pool account list [--json]
bb pool account remove <id>
bb pool account enable <id>
bb pool account disable <id>
bb pool status [--json] [--show-key]
```

The hub starts immediately, even before an account is configured, so newly
added or enabled accounts are available without a plugin reload. Only
`status --show-key` reveals the hub bearer key. Agents should use
`--api-key-stdin`, which reads exactly one non-empty key from piped standard
input. The compatibility form `--api-key <key>` exposes the key in process
arguments, shell history, and agent transcripts. Prefer `--import` when Claude
Code is already signed in. JSON account status reports rejected upstream
bucket resets under `bucketExhaustion`; this is diagnostic status and does not
affect selection.
The `upstreamBaseUrl` setting exists for tests and QA and defaults to
`https://api.anthropic.com`; `switchThreshold` defaults to `0.98`.

The builtin Keep Awake plugin prevents macOS idle sleep while bb is running.
Its settings page lets you target all hosts or selected hosts. The CLI
equivalents are:
Expand Down
33 changes: 33 additions & 0 deletions plugins/account-pool/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "bb-plugin-account-pool",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Routes Anthropic Messages API traffic across a pool of Claude accounts.",
"engines": {
"bb": ">=0.0"
},
"bb": {
"name": "Account Pool",
"description": "Routes Anthropic Messages API traffic across a pool of Claude accounts.",
"branding": {
"icon": "Layers"
},
"server": "./src/server.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --config vitest.config.ts"
},
"dependencies": {
"zod": "^4.3.6"
},
"devDependencies": {
"@get-bb/plugin-sdk": "workspace:*",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.0.0",
"better-sqlite3": "12.10.0",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vitest": "^4.1.1"
}
}
Loading
Loading