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
35 changes: 35 additions & 0 deletions apps/app/src/lib/plugin-frontend-reload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { PluginSlotMount } from "@/components/plugin/PluginSlotMount";
import { PLUGIN_PANEL_ROUTE_PATH } from "./route-paths";
import { applyAppThemeCss } from "./themes";
import { PluginPanelView } from "@/views/PluginPanelView";
import { makeInstalledPlugin } from "@/test/fixtures/plugins";

function candidate(
pluginId: string,
Expand Down Expand Up @@ -130,6 +131,40 @@ function makeDeps(initial: PluginFrontendCandidate[] = []): TestReconcileDeps {
}

describe("reconcilePluginFrontends", () => {
it.each(["running", "needs-configuration", "degraded"] as const)(
"loads frontend candidates for a plugin with %s status",
async (status) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
plugins: [
makeInstalledPlugin({
id: "account-pool",
status,
app: {
hasApp: true,
bundle: candidate("account-pool", "v1").bundle,
},
}),
],
}),
{ headers: { "content-type": "application/json" } },
),
),
);

await expect(fetchFrontendCandidates(queryClient)).resolves.toEqual([
candidate("account-pool", "v1"),
]);
},
);

it("re-imports a plugin exactly once when its bundle hash changes, replacing registrations wholesale", async () => {
const state = createPluginFrontendReconcileState();
const deps = makeDeps([
Expand Down
6 changes: 5 additions & 1 deletion apps/app/src/lib/plugin-frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,11 @@ export async function fetchFrontendCandidates(
logoDarkUrl: plugin.logoDarkUrl,
icons: new Map(Object.entries(plugin.icons)),
});
if (plugin.status !== "running") {
if (
plugin.status !== "running" &&
plugin.status !== "needs-configuration" &&
plugin.status !== "degraded"
) {
continue;
}
const bundle = plugin.app.bundle;
Expand Down
12 changes: 7 additions & 5 deletions apps/cli/src/__tests__/plugin-cli-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ describe("runPluginCliCommand", () => {
]);
});

it("materializes an API key from stdin only in the proxied request", async () => {
it("materializes an arbitrary stdin flag only in the proxied request", async () => {
const requests: string[][] = [];
vi.stubGlobal(
"fetch",
Expand All @@ -517,21 +517,23 @@ describe("runPluginCliCommand", () => {
const input = {
isTTY: false,
async *[Symbol.asyncIterator]() {
yield Buffer.from("sk-from-stdin\n");
yield Buffer.from("opaque-credential\n");
},
};
const argv = ["deploy", "--credential-stdin", "--format", "json"];

await expect(
runPluginCliCommand(
"http://localhost",
"account-pool",
["account", "add", "--provider", "claude", "--api-key-stdin"],
"fixture",
argv,
{ stdout: output, stderr: output },
input,
),
).resolves.toBe(0);
expect(argv).toEqual(["deploy", "--credential-stdin", "--format", "json"]);
expect(requests).toEqual([
["account", "add", "--provider", "claude", "--api-key", "sk-from-stdin"],
["deploy", "--credential", "opaque-credential", "--format", "json"],
]);
expect(writes).toEqual([]);
});
Expand Down
49 changes: 27 additions & 22 deletions apps/cli/src/plugin-cli-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,45 +283,50 @@ interface PluginCliInputStream extends AsyncIterable<Buffer | string> {
isTTY?: boolean;
}

const PLUGIN_CLI_SECRET_STDIN_MAX_BYTES = 16 * 1024;
const PLUGIN_CLI_STDIN_MAX_BYTES = 16 * 1024;
const PLUGIN_CLI_STDIN_FLAG = /^--([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)-stdin$/u;

async function materializeApiKeyStdin(
async function materializeStdinFlag(
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.");
const matches = argv.flatMap((flag, index) => {
const match = PLUGIN_CLI_STDIN_FLAG.exec(flag);
const name = match?.[1];
return name === undefined ? [] : [{ flag, index, name }];
});
if (matches.length === 0) return [...argv];
if (matches.length > 1) throw new Error("Choose only one stdin input flag.");
const match = matches[0];
if (match === undefined) return [...argv];
const valueFlag = `--${match.name}`;
if (argv.includes(valueFlag)) {
throw new Error(`Choose only one of ${match.flag} and ${valueFlag}.`);
}
if (input.isTTY === true) {
throw new Error("--api-key-stdin requires an API key piped on stdin.");
throw new Error(`${match.flag} requires piped 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.");
if (bytes > PLUGIN_CLI_STDIN_MAX_BYTES) {
throw new Error(`${match.flag} input exceeds 16 KiB.`);
}
chunks.push(buffer);
}
const apiKey = Buffer.concat(chunks)
const value = 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.");
.replace(/\r?\n$/u, "");
if (value.length === 0 || /[\r\n]/u.test(value)) {
throw new Error(`${match.flag} requires exactly one non-empty stdin line.`);
}
const index = indexes[0];
if (index === undefined) return [...argv];
return [
...argv.slice(0, index),
"--api-key",
apiKey,
...argv.slice(index + 1),
...argv.slice(0, match.index),
valueFlag,
value,
...argv.slice(match.index + 1),
];
}

Expand Down Expand Up @@ -360,7 +365,7 @@ export async function runPluginCliCommand(
): Promise<number> {
let resolvedArgv: string[];
try {
resolvedArgv = await materializeApiKeyStdin(argv, input);
resolvedArgv = await materializeStdinFlag(argv, input);
} catch (error) {
await writePluginCliOutput(
streams.stderr,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ credentials, and inspect its proxy route and account quota with:

```sh
bb plugin enable account-pool
bb pool account add --provider claude --login
printf '%s\n' "$CLAUDE_AUTH_CODE" | bb pool account login-complete --session <id> --code-stdin
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>]
Expand All @@ -69,7 +71,11 @@ bb pool token rotate --machine <id-or-name>
bb pool bypass <thread-id> [--off]
```

Newly added or enabled accounts are available without a plugin reload. With an
`--login` starts a PKCE session, prints a browser URL and session ID, then
exits. Pipe the manual Claude callback code to `account login-complete` with
that session ID within ten minutes. The code stays out of process arguments,
and the browser and bb server may be on different machines. Newly added or
enabled accounts are available without a plugin reload. With an
enabled account whose secret file remains readable and valid, Claude Code
sessions receive the pool route and a distinct secret token for their machine.
Tokens are never printed. `status` prunes tokens for unenrolled machines and
Expand Down
11 changes: 11 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,10 +621,21 @@ Enable it and add at least one account:

```sh
bb plugin enable account-pool
bb pool account add --provider claude --login
printf '%s\n' "$CLAUDE_AUTH_CODE" | bb pool account login-complete --session <id> --code-stdin
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 login start command creates a ten-minute in-memory PKCE session, prints a
Claude browser authorization URL and session ID, then exits. After sign-in,
pipe the code shown on Anthropic's manual callback page to
`account login-complete` with that session ID. The browser can be on a different
machine from the bb server, and the code stays out of process arguments. The
Account Pool plugin settings page exposes the same flow with **Sign in to
Claude**, plus the account list, import, API-key, enable/disable, and removal
controls.

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
Expand Down
9 changes: 9 additions & 0 deletions packages/templates/src/templates/bb-guide-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Messages API requests through the bb server. Enable it and add an account:

```
bb plugin enable account-pool
bb pool account add --provider claude --login
printf '%s\n' "$CLAUDE_AUTH_CODE" | bb pool account login-complete --session <id> --code-stdin
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>]
Expand All @@ -41,6 +43,13 @@ bb pool token rotate --machine <id-or-name>
bb pool bypass <thread-id> [--off]
```

`--login` starts a ten-minute in-memory PKCE session, prints the Claude browser
sign-in URL and session ID, then exits. After sign-in, pipe the manual callback
code to `account login-complete` with that session ID. The browser does not need
to run on the bb server machine, and neither the code nor account tokens enter
process arguments. The same flow is available in the plugin settings page
through the **Sign in to Claude** button.

The hub starts immediately, even before an account is configured, so newly
added or enabled accounts are available without a plugin reload. With an
enabled account whose secret file remains readable and valid, the plugin
Expand Down
127 changes: 127 additions & 0 deletions plugins/account-pool/app.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app";
import type { AccountSummary } from "./src/contracts.js";

const app = await loadPluginApp(() => import("./app"));

afterEach(cleanup);

function account(): AccountSummary {
return {
id: "11111111-1111-4111-8111-111111111111",
provider: "claude",
kind: "oauth",
label: "Personal Claude",
email: "person@example.com",
subscriptionType: "max",
rateLimitTier: "default_claude_max_5x",
enabled: true,
priority: 100,
createdAt: 1,
fiveHourUtilization: 0.21,
fiveHourResetAt: null,
fiveHourStatus: null,
sevenDayUtilization: 0.43,
sevenDayResetAt: null,
sevenDayStatus: null,
representativeClaim: null,
bucketExhaustion: {},
observedAt: 1,
heldUntil: null,
error: null,
inFlight: 0,
status: "ready",
};
}

describe("Account Pool settings", () => {
it("completes the browser login step and refreshes the account list", async () => {
const accounts: AccountSummary[] = [];
const opened: string[] = [];
const slot = renderSlot(
app.settingsSections[0]!,
{},
{
openUrl: (url) => {
opened.push(url);
return true;
},
rpc: {
"account.list": () => [...accounts],
"login.start": () => ({
sessionId: "22222222-2222-4222-8222-222222222222",
authorizeUrl: "https://claude.ai/oauth/authorize?state=state",
}),
"login.complete": () => {
const added = account();
accounts.push(added);
return added;
},
},
},
);

expect(await slot.findByText("No Claude accounts yet")).toBeTruthy();
fireEvent.click(slot.getByRole("button", { name: "Sign in to Claude" }));
expect(await slot.findByText("Finish signing in to Claude")).toBeTruthy();
expect(opened).toEqual(["https://claude.ai/oauth/authorize?state=state"]);
fireEvent.change(slot.getByLabelText("Claude authorization code"), {
target: { value: "code#state" },
});
fireEvent.click(slot.getByRole("button", { name: "Complete sign-in" }));

expect(await slot.findByText("Personal Claude")).toBeTruthy();
expect(slot.getByText("person@example.com")).toBeTruthy();
expect(slot.getByText("5h 21%")).toBeTruthy();
expect(slot.getByText("7d 43%")).toBeTruthy();
expect(slot.queryByText("Finish signing in to Claude")).toBeNull();
expect(slot.rpcCalls).toContainEqual({
method: "login.complete",
input: {
sessionId: "22222222-2222-4222-8222-222222222222",
pasted: "code#state",
},
});
});

it("keeps the login step open and shows a completion error inline", async () => {
const slot = renderSlot(
app.settingsSections[0]!,
{},
{
openUrl: () => true,
rpc: {
"account.list": () => [],
"login.start": () => ({
sessionId: "22222222-2222-4222-8222-222222222222",
authorizeUrl: "https://claude.ai/oauth/authorize?state=state",
}),
"login.complete": () => {
throw new Error("OAuth state mismatch. Start again.");
},
},
},
);

fireEvent.click(
await slot.findByRole("button", { name: "Sign in to Claude" }),
);
fireEvent.change(await slot.findByLabelText("Claude authorization code"), {
target: { value: "code#wrong" },
});
fireEvent.click(slot.getByRole("button", { name: "Complete sign-in" }));

const alert = await slot.findByRole("alert");
expect(alert.textContent).toContain("OAuth state mismatch. Start again.");
expect(slot.getByText("Finish signing in to Claude")).toBeTruthy();
await waitFor(() =>
expect(
slot
.getByRole("button", { name: "Complete sign-in" })
.getAttribute("disabled"),
).toBeNull(),
);
});
});
Loading
Loading