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
2 changes: 2 additions & 0 deletions packages/bb-plugin-agent-plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ bb plugin install git:https://github.com/patleeman/bb-plugins.git --subdirectory

The MCP client delegates authentication to the official SDK, including protected-resource/authorization-server discovery, dynamic client registration, PKCE, refresh, `WWW-Authenticate`, insufficient-scope step-up, and OAuth callback completion. Tokens and PKCE state are stored in a BB secret setting; they are never placed in the database snapshot or frontend payload.

For OAuth-protected MCP servers such as Fastmail, the registered redirect uses `http://localhost` on BB's port. If BB runs on another machine, sign in with your normal browser, then copy the **full** localhost URL from its address bar after the connection fails and paste it into the matching server's **Finish sign-in** field in BB. The URL contains a short-lived, one-use authorization code: paste it only into BB, never into chat or logs. BB verifies the callback URL, state and PKCE grant before exchanging the code. If the browser runs on the BB host or you have a port forward, the callback completes normally without pasting. Fastmail's MCP dynamic registration does not accept arbitrary public HTTPS redirect URIs; setting `BB_APP_URL` does not change this callback.

This v0 release installs one package instance at a time. Remove an existing
plugin before installing the same package name again; removal keeps its data
unless you choose to purge it.
Expand Down
51 changes: 51 additions & 0 deletions packages/bb-plugin-agent-plugins/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,32 @@ function InstallBar({ onDone }: { onDone: () => void }) {
);
}

function CallbackPasteForm({
pluginId, serverId, busy, onFinish,
}: {
pluginId: string;
serverId: string;
busy: boolean;
onFinish: (url: string) => Promise<boolean>;
}) {
const [callbackUrl, setCallbackUrl] = useState("");
return (
<form className="mt-3 space-y-2" onSubmit={(event) => {
event.preventDefault();
if (!callbackUrl.trim()) return;
void onFinish(callbackUrl.trim()).then((completed) => { if (completed) setCallbackUrl(""); });
}}>
<label className="block text-[11px] leading-snug text-muted-foreground" htmlFor={`mcp-callback-${pluginId}-${serverId}`}>
If your browser cannot open localhost after sign-in, copy its full address and paste it here. The URL contains a one-use code; do not paste it into chat.
</label>
<div className="flex flex-wrap gap-2">
<Input id={`mcp-callback-${pluginId}-${serverId}`} aria-label={`Callback URL for ${serverId}`} type="url" className="min-w-0 flex-1 text-xs" value={callbackUrl} onChange={(event) => setCallbackUrl(event.target.value)} autoComplete="off" spellCheck={false} disabled={busy} />
<Button type="submit" size="sm" variant="outline" disabled={busy || !callbackUrl.trim()}>{busy ? "Finishing…" : "Finish sign-in"}</Button>
</div>
</form>
);
}

function PluginRow({
plugin,
update,
Expand All @@ -288,6 +314,7 @@ function PluginRow({
onReconnect,
onReauthorize,
onDisconnect,
onFinishAuthentication,
onSkillEnabledChange,
onMcpEnabledChange,
onUpdate,
Expand All @@ -303,6 +330,7 @@ function PluginRow({
onReconnect: (p: string, s: string) => void;
onReauthorize: (p: string, s: string) => void;
onDisconnect: (p: string, s: string) => void;
onFinishAuthentication: (p: string, s: string, url: string) => Promise<boolean>;
onSkillEnabledChange: (pluginId: string, skillName: string, enabled: boolean) => void;
onMcpEnabledChange: (pluginId: string, serverId: string, enabled: boolean) => void;
onUpdate: (pluginId: string) => void;
Expand Down Expand Up @@ -476,6 +504,9 @@ function PluginRow({
{authStatus === "authorizing" ? "Continue authentication" : "Authenticate"} {srv.serverId}
</Button>
)}
{canManageAuth && srv.enabled && authStatus === "authorizing" && (
<CallbackPasteForm pluginId={plugin.id} serverId={srv.serverId} busy={isAuthBusy} onFinish={(url) => onFinishAuthentication(plugin.id, srv.serverId, url)} />
)}
{canManageAuth && srv.enabled && (srv.status === "ready" || srv.status === "error" || (srv.status === "idle" && authStatus === "authenticated")) && (
<Button size="sm" variant="outline" className="mt-2 h-7 text-xs" disabled={isAuthBusy} onClick={() => onReconnect(plugin.id, srv.serverId)}>
Reconnect {srv.serverId}
Expand Down Expand Up @@ -746,6 +777,25 @@ function AgentPluginsView() {
}
};

const finishAuthentication = async (p: string, s: string, callbackUrl: string): Promise<boolean> => {
const key = `mcp-auth:${p}:${s}`;
setPendingAction(key);
setLocalErr(null);
try {
await rpc.call("finishAuthentication", { id: p, serverId: s, callbackUrl });
toast.success(`${s} connected`, { duration: 4000 });
return true;
} catch {
const message = "Could not finish sign-in. Check that you pasted the full, most recent localhost URL and try again.";
setLocalErr(message);
notifyError(message, `agent-plugins:${key}:error`);
return false;
} finally {
setPendingAction(null);
await load();
}
};

const authenticate = (p: string, s: string) => { void runAuthAction("authenticate", p, s); };
const reconnect = (p: string, s: string) => { void runAuthAction("reconnect", p, s); };
const reauthorize = (p: string, s: string) => { void runAuthAction("reauthorize", p, s); };
Expand Down Expand Up @@ -834,6 +884,7 @@ function AgentPluginsView() {
onReconnect={reconnect}
onReauthorize={reauthorize}
onDisconnect={disconnect}
onFinishAuthentication={finishAuthentication}
onSkillEnabledChange={setSkillEnabled}
onMcpEnabledChange={setMcpEnabled}
onUpdate={updatePlugin}
Expand Down
13 changes: 8 additions & 5 deletions packages/bb-plugin-agent-plugins/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { McpGateway, type McpStdioCatalog, type McpStdioHost } from "./src/gatew
import { mcpHostContract, mcpHostSignals } from "./src/host-contract.js";
import { DEFAULT_TOOL_SEARCH_LIMIT, MAX_DESCRIBE_TOOLS, MAX_TOOL_SEARCH_LIMIT, describeTools, searchTools } from "./src/tool-search.js";
import { DeferredOAuthCredentialStore, McpOAuthProvider, type OAuthCredentialRecord } from "./src/oauth.js";
import { oauthCallbackUrl, validatePastedCallback } from "./src/callback-url.js";
import { parseSource, fetchSource, probeSource } from "./src/source.js";
import { materializeSkill, unmaterializeSkill } from "./src/skills-impl.js";
import { ensureDir, hashDirectory, rimraf, atomicRename, LIMITS } from "./src/safe-fs.js";
Expand Down Expand Up @@ -257,8 +258,7 @@ export default async function plugin(bb: BbPluginApi) {
stdioHost,
oauth: {
async getProvider(pluginId, serverId, serverUrl) {
const redirect = new URL(`/api/v1/plugins/${encodeURIComponent(bb.pluginId)}/http/oauth/callback`, bb.server.loopbackBaseUrl);
redirect.search = new URLSearchParams({ pluginId, serverId }).toString();
const redirect = oauthCallbackUrl(bb.server.loopbackBaseUrl, bb.pluginId, pluginId, serverId);
return new McpOAuthProvider(`${pluginId}:${serverId}`, serverUrl, redirect, oauthCredentialStore);
},
},
Expand Down Expand Up @@ -362,6 +362,8 @@ export default async function plugin(bb: BbPluginApi) {
}
}

// Provider redirects have no BB browser session. OAuth state and PKCE, not
// route authentication, authorize this one-use callback.
bb.http.route("GET", "/oauth/callback", async (context) => {
const url = new URL(context.req.url);
const pluginId = url.searchParams.get("pluginId");
Expand All @@ -375,7 +377,7 @@ export default async function plugin(bb: BbPluginApi) {
bb.log.warn(`[agent-plugins] OAuth callback failed for ${serverId}: ${errorText(e)}`);
return new Response("<p>Authentication failed. Return to BB and try again.</p>", { status: 400, headers: { "content-type": "text/html; charset=utf-8" } });
}
});
}, { auth: "none" });

async function approveServer(id: string, serverId: string): Promise<void> {
const p = store.getPlugin(id) ?? store.getPluginByName(id);
Expand Down Expand Up @@ -1129,8 +1131,9 @@ export default async function plugin(bb: BbPluginApi) {
async finishAuthentication({ id, serverId, callbackUrl }) {
const p = store.getPlugin(id) ?? store.getPluginByName(id);
if (!p) throw new Error(`not found: ${id}`);
const callback = new URL(callbackUrl);
await withDeferredOAuthPersistence(() => gateway.finishAuth(p.id, serverId, callback.searchParams));
const expected = oauthCallbackUrl(bb.server.loopbackBaseUrl, bb.pluginId, p.id, serverId);
const params = validatePastedCallback(callbackUrl, expected);
await withDeferredOAuthPersistence(() => gateway.finishAuth(p.id, serverId, params));
await publishChanged({ kind: "oauth", id: p.id, serverId });
return { authenticated: true };
},
Expand Down
38 changes: 38 additions & 0 deletions packages/bb-plugin-agent-plugins/src/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,4 +400,42 @@ describe("Agent Plugins settings", () => {
}));
await slot.findByRole("button", { name: "Authenticate fastmail" });
});

it("finishes remote sign-in with a pasted localhost callback without showing the code in a message", async () => {
const callbackUrl = "http://localhost:38886/api/v1/plugins/agent-plugins/http/oauth/callback?pluginId=plugin-1&serverId=fastmail&code=one-use-secret&state=nonce";
let snapshot = {
plugins: [{
id: "plugin-1", name: "Fastmail", version: "1.0.0", specVersion: "1.0.0",
sourceType: "path", sourceIntent: "path:/tmp/fastmail", sourceResolved: null,
status: "active", approval: "approved", lastError: null,
}],
skills: [],
mcpServers: [{
pluginId: "plugin-1", serverId: "fastmail", type: "streamable-http", status: "needs-auth",
authStatus: "authorizing", lastError: null as string | null, approved: 1, enabled: true,
configJson: JSON.stringify({ type: "streamable-http", url: "https://api.fastmail.com/mcp" }),
}],
dataDir: "/tmp/bb",
};
const calls: unknown[] = [];
const slot = renderSlot(panel, { subPath: "" }, {
rpc: {
snapshot: () => snapshot,
checkUpdates: () => ({ updates: [] }),
finishAuthentication: (input: unknown) => {
calls.push(input);
snapshot = { ...snapshot, mcpServers: [{ ...snapshot.mcpServers[0]!, status: "ready", authStatus: "authenticated" }] };
return { authenticated: true };
},
} as never,
});

fireEvent.click(await slot.findByRole("button", { name: /Fastmail/ }));
const field = await slot.findByRole("textbox", { name: "Callback URL for fastmail" });
fireEvent.change(field, { target: { value: callbackUrl } });
fireEvent.click(slot.getByRole("button", { name: "Finish sign-in" }));
await waitFor(() => expect(calls).toEqual([{ id: "plugin-1", serverId: "fastmail", callbackUrl }]));
await waitFor(() => expect(slot.queryByRole("textbox", { name: "Callback URL for fastmail" })).toBeNull());
expect(slot.queryByText(/one-use-secret/)).toBeNull();
});
});
33 changes: 33 additions & 0 deletions packages/bb-plugin-agent-plugins/src/callback-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { oauthCallbackUrl, validatePastedCallback } from "./callback-url.js";

describe("MCP OAuth loopback callback", () => {
const expected = "http://localhost:38886/api/v1/plugins/agent-plugins/http/oauth/callback?pluginId=pa-fastmail-spike&serverId=fastmail";

it("uses localhost even when BB has a remote public URL", () => {
expect(oauthCallbackUrl("http://127.0.0.1:38886", "agent-plugins", "pa-fastmail-spike", "fastmail").toString()).toBe(expected);
});

it("preserves an already-localhost loopback address", () => {
expect(oauthCallbackUrl("http://localhost:38886", "agent-plugins", "pa-fastmail-spike", "fastmail").toString()).toBe(expected);
});

it("only accepts loopback as the callback base", () => {
expect(() => oauthCallbackUrl("http://remote.example:38886", "agent-plugins", "pa-fastmail-spike", "fastmail")).toThrow();
});

it("accepts the complete pasted callback URL after browser sign-in", () => {
const params = validatePastedCallback(`${expected}&code=one-time-code&state=nonce`, new URL(expected));
expect(params.get("code")).toBe("one-time-code");
expect(params.get("state")).toBe("nonce");
});

it.each([
"https://attacker.example/oauth/callback?code=secret&state=nonce",
"http://localhost:38886/other?code=secret&state=nonce",
"http://localhost:38886/api/v1/plugins/agent-plugins/http/oauth/callback?pluginId=other&serverId=fastmail&code=secret&state=nonce",
"http://localhost:38886/api/v1/plugins/agent-plugins/http/oauth/callback?pluginId=pa-fastmail-spike&serverId=other&code=secret&state=nonce",
])("rejects a callback from a different URL or server without returning it (%s)", (pasted) => {
expect(() => validatePastedCallback(pasted, new URL(expected))).toThrow("Callback URL does not match this MCP connection");
});
});
31 changes: 31 additions & 0 deletions packages/bb-plugin-agent-plugins/src/callback-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Fastmail's dynamic registration accepts localhost but not an arbitrary
// HTTPS callback. A remote BB user can paste the failed browser redirect into
// BB; the OAuth state/PKCE grant still authorizes the code exchange.
export function oauthCallbackUrl(loopbackBaseUrl: string, bridgeId: string, pluginId: string, serverId: string): URL {
const base = new URL(loopbackBaseUrl);
if (base.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname) || base.username || base.password) {
throw new Error("MCP OAuth requires a local BB loopback URL");
}
base.hostname = "localhost";
const redirect = new URL(`/api/v1/plugins/${encodeURIComponent(bridgeId)}/http/oauth/callback`, base);
redirect.search = new URLSearchParams({ pluginId, serverId }).toString();
return redirect;
}

export function validatePastedCallback(pasted: string, expected: URL): URLSearchParams {
// Never include the supplied URL in an error: it carries a one-use code.
if (pasted.length > 8192) throw new Error("Callback URL is too long");
let actual: URL;
try { actual = new URL(pasted); }
catch { throw new Error("Paste the full callback URL from your browser's address bar"); }
const expectedQuery = expected.searchParams;
const actualQuery = actual.searchParams;
if (actual.protocol !== expected.protocol ||
![expected.hostname, "127.0.0.1", "[::1]"].includes(actual.hostname) ||
actual.port !== expected.port || actual.pathname !== expected.pathname ||
actual.username || actual.password || actual.hash ||
[...expectedQuery.keys()].some((key) => actualQuery.getAll(key).length !== 1 || actualQuery.get(key) !== expectedQuery.get(key))) {
throw new Error("Callback URL does not match this MCP connection");
}
return actualQuery;
}
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,11 @@ describe("MCP SDK OAuth integration", () => {
const state = new URL(url!).searchParams.get("state");
expect(state).toBeTruthy();

await expect(gateway.finishAuth(plugin.id, "fastmail", new URLSearchParams({ code: "fixture-code" }))).rejects.toThrow("state mismatch");
await expect(gateway.finishAuth(plugin.id, "fastmail", new URLSearchParams({ code: "fixture-code", state: "wrong" }))).rejects.toThrow("state mismatch");
expect(tokenRequests).toBe(0);
await gateway.finishAuth(plugin.id, "fastmail", new URLSearchParams({ code: "fixture-code", state: state! }));
await expect(gateway.finishAuth(plugin.id, "fastmail", new URLSearchParams({ code: "fixture-code", state: state! }))).rejects.toThrow("state mismatch");
const tools = await gateway.listTools();
expect(tools.map((tool) => tool.name)).toEqual(["oauth-tool"]);
expect(tokenRequests).toBe(1);
Expand Down
14 changes: 14 additions & 0 deletions packages/bb-plugin-agent-plugins/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ afterEach(async () => {
while (tempDirs.length > 0) await rimraf(tempDirs.pop()!);
});

describe("Agent Plugins OAuth route", () => {
it("accepts provider redirects without BB browser authentication and rejects missing context", async () => {
const host = createFakePluginHost({ pluginId: "agent-plugins" });
hosts.push(host);
await plugin(host.bb);
const callback = host.harness.registrations.httpRoutes.find((route) => route.path === "/oauth/callback");
expect(callback?.auth).toBe("none");
const route = callback!;
const response = await route.handler({ req: { url: "https://bb.example.test/api/v1/plugins/agent-plugins/http/oauth/callback" } } as Parameters<typeof route.handler>[0]);
expect(response.status).toBe(400);
expect(await response.text()).toContain("Missing Agent Plugins OAuth callback context");
});
});

describe("Agent Plugins update flow", () => {
it("keeps the installed identity, data path, and disabled skill state across an update", async () => {
const dataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "agent-plugins-data-"));
Expand Down