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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ web/ Vite + React + TypeScript SPA (separate package.json)

All tool handlers that access data must be workspace-scoped. Use `runtime.requireWorkspaceId()` (never `getCurrentWorkspaceId()`). In dev mode it returns `"_dev"` — no special-case logic needed.

When adding a new code path that touches workspace-scoped credentials or identity, match the existing precedent: **hard-error on missing `wsId`, don't silently default**. `startBundleSource`'s named-bundle branch throws; the URL-bundle branch does too (for OAuth-provider paths). A `?? "ws_default"` fallback would pool credentials across tenants.

## Debug Logging

Hot-path diagnostics are gated behind namespace flags so they're available when you need them without editing source. Use for tracing across the runtime ↔ SSE ↔ browser ↔ iframe chain.
Expand Down
6 changes: 6 additions & 0 deletions src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { conversationEventRoutes } from "./routes/conversation-events.ts";
import { eventRoutes } from "./routes/events.ts";
import { healthRoutes } from "./routes/health.ts";
import { mcpRoutes } from "./routes/mcp.ts";
import { mcpAuthRoutes } from "./routes/mcp-auth.ts";
import { resourceRoutes } from "./routes/resources.ts";
import { toolRoutes } from "./routes/tools.ts";
import { wellKnownRoutes } from "./routes/well-known.ts";
Expand All @@ -29,6 +30,11 @@ export function createApp(
app.route("/", wellKnownRoutes(ctx));
app.route("/", healthRoutes(ctx));
app.route("/", authRoutes(ctx));
// Outbound-OAuth callback for remote MCP servers. Unauthenticated by
// design — state param guards against unsolicited codes. Must be
// reachable before any authenticated middleware; ordering alongside
// authRoutes keeps that invariant obvious.
app.route("/", mcpAuthRoutes(ctx));

// MCP routes BEFORE other authenticated routes — prevents other sub-app
// wildcard middleware from intercepting /mcp requests. Hono runs use("*")
Expand Down
88 changes: 88 additions & 0 deletions src/api/routes/mcp-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Hono } from "hono";
import { resolveWithCode } from "../../tools/oauth-flow-registry.ts";
import type { AppContext } from "../types.ts";

/**
* Callback endpoint for outbound OAuth flows where NimbleBrain is acting as
* the client against a remote MCP server's authorization server. Pairs with
* `WorkspaceOAuthProvider`: when the provider's flow requires a real browser
* round-trip, the remote authorization server redirects the user's browser
* here with `?code=<code>&state=<state>`.
*
* The route looks up the pending flow by `state` via the process-local
* `oauth-flow-registry`, resolves it with the code, and shows a minimal
* "done" page so the user can close the tab.
*
* Unauthenticated by design — it's the return leg of an OAuth flow the user
* explicitly initiated by adding a remote bundle. State param prevents
* unsolicited code injection; unknown states 400 cleanly.
*
* MVP note: Reboot's `Anonymous` dev OAuth is handled entirely inside
* `WorkspaceOAuthProvider.redirectToAuthorization` (headless) — the
* authorization URL self-targets this route with the code already embedded,
* and the provider resolves the deferred in-process without making an HTTP
* request. This route is kept in place as the extension point for real
* interactive providers (follow-up iteration).
*/
export function mcpAuthRoutes(_ctx: AppContext) {
const app = new Hono();

app.get("/v1/mcp-auth/callback", (c) => {
// Belt-and-suspenders: an intermediate proxy caching the success page
// (with `?code=...` in the URL) in a shared cache space is a classic
// OAuth footgun. Codes are single-use so the real boundary is the
// flow registry, but explicitly marking the response non-cacheable
// kills the class entirely.
c.header("Cache-Control", "no-store");
c.header("Pragma", "no-cache");

const code = c.req.query("code");
const state = c.req.query("state");
const error = c.req.query("error");

if (error) {
return c.html(
`<html><body><h3>Authorization failed</h3><pre>${escapeHtml(error)}</pre></body></html>`,
400,
);
}
if (!code || !state) {
return c.text("missing code or state", 400);
}

const matched = resolveWithCode(state, code);
if (!matched) {
return c.html(
"<html><body><h3>Unknown or expired OAuth flow.</h3>" +
"<p>Re-initiate the connection from NimbleBrain.</p></body></html>",
404,
);
}

return c.html(
"<html><body><h3>Authorization complete.</h3>" +
"<p>You can close this tab and return to NimbleBrain.</p></body></html>",
);
});

return app;
}

function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (ch) => {
switch (ch) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
case "'":
return "&#39;";
default:
return ch;
}
});
}
9 changes: 8 additions & 1 deletion src/bundles/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,19 @@ export class BundleLifecycleManager {
ui?: BundleUiMeta | null,
trustScore?: number | null,
): Promise<BundleInstance> {
// Thread wsId + workDir through to startBundleSource so the URL-bundle
// branch can key OAuth credentials by (wsId, serverName) instead of
// falling back to `ws_default`. Without this, a URL bundle installed
// via `installRemote` from any workspace would share OAuth tokens across
// workspaces under the default id — silent cross-tenant credential
// leakage.
const nbWorkDir = process.env.NB_WORK_DIR ?? join(homedir(), ".nimblebrain");
const { sourceName, meta } = await startBundleSource(
{ url, serverName, transport: transportConfig, ui: ui ?? null },
registry,
this.eventSink,
this.configPath ? dirname(this.configPath) : undefined,
{ allowInsecureRemotes: this.allowInsecureRemotes },
{ allowInsecureRemotes: this.allowInsecureRemotes, wsId, workDir: nbWorkDir },
);

const instance: BundleInstance = {
Expand Down
47 changes: 47 additions & 0 deletions src/bundles/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { EventSink } from "../engine/types.ts";
import { McpSource } from "../tools/mcp-source.ts";
import type { ToolRegistry } from "../tools/registry.ts";
import type { ToolSource } from "../tools/types.ts";
import { WorkspaceOAuthProvider } from "../tools/workspace-oauth-provider.ts";
import { extractBundleMeta } from "./defaults.ts";
import { filterEnvForBundle } from "./env-filter.ts";
import { validateManifest } from "./manifest.ts";
Expand Down Expand Up @@ -70,12 +71,58 @@ export async function startBundleSource(
// SSRF protection: validate URL before connecting
validateBundleUrl(new URL(ref.url), { allowInsecure: opts?.allowInsecureRemotes });
log.info(`[bundles] Starting remote bundle ${ref.url} as ${sourceName}...`);

// Attach an OAuthClientProvider when no static auth is configured. The
// provider is workspace-scoped: tokens and DCR credentials live under
// <workDir>/workspaces/<wsId>/credentials/mcp-oauth/<serverName>/.
//
// `wsId` is REQUIRED here — not defaulted — to match the named-bundle
// branch's behavior at the credential boundary. A silent `ws_default`
// fallback would cause cross-tenant credential leakage: URL bundles
// installed from different workspaces would share OAuth tokens under
// the same default id. Callers must thread workspace context through
// `installRemote` / `startBundleSource`.
let authProvider: WorkspaceOAuthProvider | undefined;
const hasStaticAuth = ref.transport?.auth && ref.transport.auth.type !== "none";
if (!hasStaticAuth) {
if (!opts?.wsId) {
throw new Error(
`[bundles] URL bundle "${sourceName}" without static auth requires opts.wsId — ` +
"OAuth credentials are workspace-scoped and silent defaults would cross tenants. " +
"Thread wsId through installRemote() or the caller that invoked startBundleSource().",
);
}
const workDir = opts.workDir ?? process.env.NB_WORK_DIR ?? join(homedir(), ".nimblebrain");
const apiBase = process.env.NB_API_URL;
// Startup warning when a URL-ref bundle is being wired but NB_API_URL
// isn't set. Default is only safe for local dev — in prod (NB behind a
// proxy), the OAuth provider would hand the authorization server a
// redirect_uri pointing at the pod's localhost, which the user's
// browser can't reach. One-time log per process is enough.
if (!apiBase) {
log.warn(
`[bundles] NB_API_URL not set; OAuth callback defaults to http://localhost:27247. ` +
"In production (NB behind a proxy / on a different host from the user's browser), " +
"set NB_API_URL to the platform's externally reachable URL.",
);
}
const callbackUrl = `${(apiBase ?? "http://localhost:27247").replace(/\/+$/, "")}/v1/mcp-auth/callback`;
authProvider = new WorkspaceOAuthProvider({
wsId: opts.wsId,
serverName,
workDir,
callbackUrl,
allowInsecureRemotes: opts.allowInsecureRemotes === true,
});
}

const source = new McpSource(
sourceName,
{
type: "remote",
url: new URL(ref.url),
transportConfig: ref.transport,
authProvider,
},
eventSink,
);
Expand Down
Loading