Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
25f9ca9
feat: upload and install custom .mcpb bundles from web UI
Ovaculos May 4, 2026
986acf6
style: fix biome formatting in bundle upload files
Ovaculos May 5, 2026
e2a6c74
fix: remove unused import and sort imports for lint
Ovaculos May 5, 2026
af5568e
test: add failing tests for PR #170 .mcpb upload fixes
Ovaculos May 6, 2026
a6721b5
fix: prevent path traversal in .mcpb bundle upload
Ovaculos May 6, 2026
593fd3f
fix: thread credentials and env vars through .mcpb startup branch
Ovaculos May 6, 2026
6aa45b3
fix: derive .mcpb server name and dataDir from manifest, not file path
Ovaculos May 6, 2026
e41c9fc
test: drop integration tests pending mpak-sdk@0.7.0 release
Ovaculos May 6, 2026
c371c60
fix: thread .mcpb path through uninstall, repair workspace.json filter
Ovaculos May 6, 2026
a5ced45
fix(types): infer Request.formData() return type
Ovaculos May 6, 2026
1c6c6ca
refactor(api): hoist node:fs and mpak-sdk imports in handleBundleUpload
Ovaculos May 6, 2026
bd8bfed
fix(bundles): confine manage_app.path to workspace bundles dir
Ovaculos May 6, 2026
9fcd4c8
fix(api): validate uploaded .mcpb in tempfile before commit to bundle…
Ovaculos May 6, 2026
a2920cd
fix(api): randomize uploaded .mcpb filename to prevent silent overwrite
Ovaculos May 6, 2026
47ffd53
fix(api): enforce MAX_BUNDLE_SIZE post-buffer in handleBundleUpload
Ovaculos May 6, 2026
530c241
refactor(api): extract UploadedFileEntry helper, adopt in upload hand…
Ovaculos May 6, 2026
b215974
feat(web): track upload phase explicitly in AboutTab
Ovaculos May 6, 2026
ed5f8c4
test: import production filter in mcpb-upload unit test
Ovaculos May 6, 2026
665358a
test: cover .mcpb path guard and upload integration
Ovaculos May 6, 2026
cfcba62
docs: changelog entry for .mcpb upload feature
Ovaculos May 6, 2026
1cd6c03
fix(rebase): drop duplicate bundleEntryMatchesTarget, narrow AboutTab…
Ovaculos May 11, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
- Top-level `/profile` route. Identity isn't a setting; un-nested from `/settings/*`.
- Org-admin gate on `set_model_config` — backend now refuses non-org-admin writes (was UI-only via RouteGuard). Distinguishes "no identity" (cron, automations) from "wrong role" so debug logs make non-user code paths obvious.
- HTTP proxy primitive (`_meta["ai.nimblebrain/http-proxy"]`). Bundles can expose a loopback HTTP server (e.g. `astro preview`, Jupyter kernel) through the platform at `/v1/ws/<wsId>/apps/<bundle>/<mount>/*`. Loopback-only target, credentials and `Accept-Encoding` stripped on forward, `Set-Cookie`/CSP/X-Frame-Options stripped on response, per-workspace kill switch via `Workspace.allowHttpProxy`. Bundles get `NB_WORKSPACE_ID`, `NB_PROXY_PREFIX`, `NB_PUBLIC_ORIGIN` in their env at spawn ([docs](https://docs.nimblebrain.ai/apps/http-proxy/)).
- Upload custom `.mcpb` bundles from Settings → About. Multipart upload to `POST /v1/bundles/upload` validates the archive via `@nimblebrain/mpak-sdk` in a tempfile, then commits to the workspace bundles dir. `manage_app` gains a `path` parameter for file-based install — confined to the workspace bundles dir at install, uninstall, and startup re-hydration so a prompt-injected agent cannot spawn arbitrary `.mcpb` artifacts under workspace credentials. `.mcpb` paths persist in `workspace.json` and survive restart ([#170](https://github.com/NimbleBrainInc/nimblebrain/pull/170)).

### Changed

Expand Down
2 changes: 2 additions & 0 deletions src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { corsMiddleware } from "./middleware/cors.ts";
import { securityHeaders } from "./middleware/security-headers.ts";
import { authRoutes } from "./routes/auth.ts";
import { bootstrapRoutes } from "./routes/bootstrap.ts";
import { bundleRoutes } from "./routes/bundles.ts";
import { chatRoutes } from "./routes/chat.ts";
import { conversationEventRoutes } from "./routes/conversation-events.ts";
import { eventRoutes } from "./routes/events.ts";
Expand Down Expand Up @@ -56,6 +57,7 @@ export function createApp(
app.route("/", chatRoutes(ctx));
app.route("/", toolRoutes(ctx));
app.route("/", resourceRoutes(ctx));
app.route("/", bundleRoutes(ctx));
app.route("/", eventRoutes(ctx));
app.route("/", conversationEventRoutes(ctx));

Expand Down
185 changes: 166 additions & 19 deletions src/api/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
import { validateMcpb } from "@nimblebrain/mpak-sdk";
import { CallbackEventSink } from "../adapters/callback-events.ts";
import { log } from "../cli/log.ts";
import { isToolEnabled, isToolVisibleToRole, type ResolvedFeatures } from "../config/features.ts";
Expand All @@ -23,7 +26,7 @@ import type { SseEventManager } from "./events.ts";
import { ChatRequestBody, ToolCallRequestEnvelope } from "./schemas/rest.ts";
import { validateAgainst } from "./schemas/validate.ts";
import { startSseHeartbeat } from "./sse-heartbeat.ts";
import { apiError } from "./types.ts";
import { apiError, asUploadedFile } from "./types.ts";

const pkgPath = resolve(import.meta.dirname ?? __dirname, "../../package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string };
Expand Down Expand Up @@ -1040,6 +1043,41 @@ export function sanitizeFilename(name: string): string {
return name.replace(/["\r\n\x00-\x1f]/g, "_");
}

/**
* Maximum byte size of an uploaded `.mcpb` archive. Shared with the
* route-level `bodyLimit(..., { multipart: MAX_BUNDLE_SIZE })` so the
* `Content-Length`-advisory check and the post-buffer authoritative check
* stay in lockstep.
*/
export const MAX_BUNDLE_SIZE = 200 * 1024 * 1024; // 200 MB

/**
* Resolve the safe on-disk filename for an uploaded `.mcpb` bundle.
*
* Two responsibilities:
*
* 1. **Path traversal defense.** Strips every directory component via
* `path.basename`, so filenames like `../../etc/cron.daily/evil.mcpb`
* collapse to `evil.mcpb` and cannot escape the workspace bundles dir
* when joined onto it. `sanitizeFilename` only neutralizes
* Content-Disposition-breaking chars and is insufficient on its own.
*
* 2. **Collision avoidance.** Two uploads named `bundle.mcpb` would
* otherwise clobber each other on disk, silently swapping the artifact
* backing a running install (the path is the workspace.json key). A
* 64-bit random hex suffix is appended before the `.mcpb` extension so
* every upload lands at a unique path. The frontend never sees this
* name — bundle display name comes from the manifest, not the filename.
*
* Exported so tests pin the contract.
*/
export function safeBundleFilename(filename: string): string {
const base = basename(filename);
const stem = base.endsWith(".mcpb") ? base.slice(0, -".mcpb".length) : base;
const suffix = randomBytes(8).toString("hex");
return `${stem}-${suffix}.mcpb`;
}

/**
* Regex for valid file IDs.
* - New scheme: `fl_<24 hex chars>` (randomBytes(12).hex).
Expand Down Expand Up @@ -1160,17 +1198,12 @@ async function parseMultipartChatBody(
}
}

// Collect uploaded files — FormDataEntryValue is string | File in Bun.
// TypeScript without DOM lib doesn't know File, so we check via duck typing.
// Collect uploaded files — see `asUploadedFile` for why we don't annotate
// entries as `File` directly (Bun/undici vs DOM-lib type mismatch).
const uploadedFiles: UploadedFile[] = [];
for (const [_key, value] of formData.entries()) {
if (typeof value === "string") continue;
const entry = value as unknown as {
arrayBuffer(): Promise<ArrayBuffer>;
name?: string;
type?: string;
};
if (typeof entry.arrayBuffer !== "function") continue;
const entry = asUploadedFile(value);
if (!entry) continue;
const buffer = Buffer.from(await entry.arrayBuffer());
uploadedFiles.push({
data: buffer,
Expand Down Expand Up @@ -1279,14 +1312,9 @@ export async function handleResourceUpload(
const uploads: UploadedFile[] = [];
try {
for (const [key, value] of formData.entries()) {
if (typeof value === "string") continue;
if (key !== "file" && key !== "files") continue;
const entry = value as unknown as {
arrayBuffer(): Promise<ArrayBuffer>;
name?: string;
type?: string;
};
if (typeof entry.arrayBuffer !== "function") continue;
const entry = asUploadedFile(value);
if (!entry) continue;
uploads.push({
data: Buffer.from(await entry.arrayBuffer()),
filename: entry.name || "unnamed",
Expand Down Expand Up @@ -1374,3 +1402,122 @@ export async function handleResourceUpload(
}
return json({ files: entries, ...(errors.length > 0 ? { errors } : {}) });
}

// ---------------------------------------------------------------------------
// Bundle upload
// ---------------------------------------------------------------------------

export async function handleBundleUpload(
raw: Request,
runtime: Runtime,
workspaceId: string,
): Promise<Response> {
// Inferred type rather than `FormData` annotation — Bun's Request.formData()
// returns the undici FormData, which has incompatible iterator types with
// the DOM-lib `FormData` resolved at the annotation site. They're shape-
// compatible at runtime; let TS infer to avoid the cross-type assignment.
let formData: Awaited<ReturnType<typeof raw.formData>>;
try {
formData = await raw.formData();
} catch {
return apiError(400, "bad_request", "Expected multipart/form-data");
}

const entry = asUploadedFile(formData.get("file") ?? formData.get("bundle"));
if (!entry) {
return apiError(
400,
"bad_request",
"No bundle file in request (use the 'file' or 'bundle' field)",
);
}

const filename = entry.name || "bundle.mcpb";
if (!filename.endsWith(".mcpb")) {
return apiError(400, "bad_request", "File must have .mcpb extension");
}

const data = Buffer.from(await entry.arrayBuffer());
if (data.length === 0) {
return apiError(400, "bad_request", "Uploaded file is empty");
}
// Authoritative size check. The route-level `bodyLimit` middleware is
// advisory: it only rejects when the client sends a `Content-Length`
// header. Chunked transfer encoding, missing headers, or a lying client
// bypass it — we only know the real size after buffering.
if (data.length > MAX_BUNDLE_SIZE) {
return apiError(413, "payload_too_large", "Bundle exceeds maximum size", {
limit: MAX_BUNDLE_SIZE,
received: data.length,
});
}

// Validate-then-commit:
//
// Write the upload to a tempfile under the OS temp dir first, run
// `validateMcpb` against it, and only `rename` into the workspace bundles
// dir on success. The previous order (write into the bundles dir, then
// validate, then unlink on failure) leaked partially-trusted artifacts:
// if unlink failed (perm/race) or the process crashed between write and
// unlink, an unvalidated `.mcpb` lingered in the bundles dir — a stale
// file the install path could later spawn. Tempfile + rename keeps the
// bundles dir to validated content only.
const bundlesDir = join(runtime.getWorkspaceScopedDir(workspaceId), "bundles");
mkdirSync(bundlesDir, { recursive: true });

const safeName = safeBundleFilename(filename);
const tempPath = join(tmpdir(), `nb-mcpb-${randomBytes(8).toString("hex")}.mcpb`);
const bundlePath = join(bundlesDir, safeName);

writeFileSync(tempPath, data, { mode: 0o600 });

let result: Awaited<ReturnType<typeof validateMcpb>>;
try {
result = await validateMcpb(tempPath);
} catch (err) {
try {
unlinkSync(tempPath);
} catch {
// best-effort cleanup
}
throw err;
}

if (!result.valid) {
try {
unlinkSync(tempPath);
} catch {
// best-effort cleanup
}
return apiError(400, "invalid_bundle", "Bundle validation failed", {
errors: result.errors,
});
}

// Validation passed — promote tempfile into the workspace bundles dir.
// `renameSync` is atomic when source and destination share a filesystem;
// when they don't (tmpdir on a separate fs), Node falls back to copy +
// unlink, which is good enough — we already hold a valid archive.
try {
renameSync(tempPath, bundlePath);
} catch (err) {
try {
unlinkSync(tempPath);
} catch {
// best-effort cleanup
}
throw err;
}

return json({
path: bundlePath,
manifest: {
name: result.manifest.name,
version: result.manifest.version,
description: result.manifest.description,
display_name: result.manifest.display_name ?? null,
server_type: result.manifest.server.type,
tools: result.manifest.tools ?? [],
},
});
}
17 changes: 17 additions & 0 deletions src/api/routes/bundles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Hono } from "hono";
import { handleBundleUpload, MAX_BUNDLE_SIZE } from "../handlers.ts";
import { requireAuth } from "../middleware/auth.ts";
import { bodyLimit } from "../middleware/body-limit.ts";
import { errorLog } from "../middleware/error-log.ts";
import { requireWorkspace } from "../middleware/workspace.ts";
import type { AppContext, AppEnv } from "../types.ts";

export function bundleRoutes(ctx: AppContext) {
return new Hono<AppEnv>()
.use("*", requireAuth(ctx.authOptions))
.use("*", requireWorkspace(ctx.workspaceStore))
.use("*", errorLog(ctx))
.post("/v1/bundles/upload", bodyLimit(1_048_576, { multipart: MAX_BUNDLE_SIZE }), (c) =>
handleBundleUpload(c.req.raw, ctx.runtime, c.var.workspaceId),
);
}
33 changes: 33 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,39 @@ import type { SseEventManager } from "./events.ts";
import type { McpServerHost } from "./mcp-server.ts";
import type { LoginRateLimiter, RequestRateLimiter } from "./rate-limiter.ts";

// ---------------------------------------------------------------------------
// Multipart upload helper
// ---------------------------------------------------------------------------

/**
* Minimal interface for an uploaded file pulled out of `Request.formData()`.
*
* Why this exists: Bun's `Request.formData()` returns the undici `FormData`,
* whose entries are undici `File` instances. The DOM-lib `File` resolved at
* an annotation site has incompatible iterator types, so a direct
* `value as File` cast produces a TS error. Every multipart handler had its
* own `value as unknown as { arrayBuffer(): Promise<ArrayBuffer>; ... }`
* cast inline; this interface centralizes the shape.
*/
export interface UploadedFileEntry {
arrayBuffer(): Promise<ArrayBuffer>;
name?: string;
type?: string;
size?: number;
}

/**
* Narrow a `FormData.get` / `FormData.entries` value to an
* `UploadedFileEntry`. Returns `null` for strings, missing values, or
* objects without an `arrayBuffer` method (which would fail at read time
* anyway). Centralises the cross-type cast so handlers don't repeat it.
*/
export function asUploadedFile(value: unknown): UploadedFileEntry | null {
if (!value || typeof value === "string") return null;
const entry = value as UploadedFileEntry;
return typeof entry.arrayBuffer === "function" ? entry : null;
}

// ---------------------------------------------------------------------------
// Standardized API error response
// ---------------------------------------------------------------------------
Expand Down
79 changes: 78 additions & 1 deletion src/bundles/paths.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { join } from "node:path";
import { existsSync, realpathSync } from "node:fs";
import { dirname, join, resolve, sep } from "node:path";
import type { BundleRef } from "./types.ts";

/** Prefixes reserved for system tools — bundles must not use these as source names. */
Expand Down Expand Up @@ -102,3 +103,79 @@ export function deriveBundleDataDir(name: string): string {
export function resolveBundleDataDir(workspacePath: string, bundleName: string): string {
return join(workspacePath, "data", deriveBundleDataDir(bundleName));
}

/**
* Resolve the absolute on-disk path of the workspace bundles directory —
* the only location an LLM-supplied `.mcpb` path is permitted to reference.
*/
export function resolveWorkspaceBundlesDir(workDir: string, wsId: string): string {
return join(workDir, "workspaces", wsId, "bundles");
}

/**
* Canonicalize a path for prefix comparison. Realpaths the file when it
* exists; when it doesn't, walks up the parent chain to find the deepest
* existing ancestor, realpaths that, then re-attaches the missing tail.
*
* The naive `existsSync(p) ? realpathSync(p) : resolve(p)` form diverges on
* platforms where ancestors are symlinks (macOS `/var` → `/private/var`):
* the bundles dir realpaths to the canonical form, the missing file does
* not, and a guard comparing the two falsely rejects.
*/
function canonicalize(filePath: string): string {
const abs = resolve(filePath);
if (existsSync(abs)) return realpathSync(abs);
// Walk up to find the deepest existing ancestor.
let parent = dirname(abs);
let suffix = abs.slice(parent.length); // includes leading sep
while (parent !== dirname(parent) && !existsSync(parent)) {
suffix = parent.slice(dirname(parent).length) + suffix;
parent = dirname(parent);
}
if (!existsSync(parent)) return abs;
return realpathSync(parent) + suffix;
}

/**
* Assert that `filePath` resolves inside `<workDir>/workspaces/<wsId>/bundles/`.
* Throws otherwise.
*
* Critical defense for the `manage_app({ path })` tool path. Without this
* guard, prompt-injected agent input can install any `.mcpb` the platform
* user can read — the spawned subprocess inherits workspace credentials and
* (for protected bundles) `NB_INTERNAL_TOKEN`. The same check runs at
* startup re-hydration so a tampered `workspace.json` cannot escape either.
*
* Both sides are realpath'd when possible so symlinks pointing outside the
* bundles dir are rejected. If the file does not exist (uninstall after
* manual deletion), falls back to a lexical resolve — workspace.json is
* written by trusted code, so a missing file is not a sign of tampering.
*/
export function assertPathInWorkspaceBundlesDir(
filePath: string,
workDir: string,
wsId: string,
): void {
const bundlesDir = resolveWorkspaceBundlesDir(workDir, wsId);
// Realpath the bundles dir if it exists; otherwise fall back to a lexical
// resolve. The dir is created on first upload, so a missing dir means no
// bundle could possibly live inside it — a guard violation either way.
const canonicalBundlesDir = existsSync(bundlesDir)
? realpathSync(bundlesDir)
: resolve(bundlesDir);
// For a missing file, realpath the parent dir and re-attach the basename.
// Pure lexical resolve diverges from the bundles-dir canonicalization on
// platforms where ancestors are themselves symlinks (notably macOS, where
// /var → /private/var). Without this, an uninstall after manual deletion
// of a perfectly-valid bundle inside the dir would falsely fail the guard.
const canonicalFile = canonicalize(filePath);
if (
canonicalFile !== canonicalBundlesDir &&
!canonicalFile.startsWith(canonicalBundlesDir + sep)
) {
throw new Error(
`Bundle path must live inside the workspace bundles directory ` +
`(${canonicalBundlesDir}); got ${canonicalFile}`,
);
}
}
Loading