mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat(cowork): re-enable Claude Cowork with preset-only stdio MCP
Restore Cowork feature while closing the RCE vector: drop user-defined stdio commands so only hard-coded preset plugins (browsermcp) may spawn. Custom MCP now accepts remote URL only. Routes stay gated to localhost. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -48,7 +48,7 @@ export default function CoworkToolCard({
|
|||||||
const [modelSelectOpen, setModelSelectOpen] = useState(false);
|
const [modelSelectOpen, setModelSelectOpen] = useState(false);
|
||||||
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
||||||
const [addMcpOpen, setAddMcpOpen] = useState(false);
|
const [addMcpOpen, setAddMcpOpen] = useState(false);
|
||||||
const [addMcpForm, setAddMcpForm] = useState({ type: "url", name: "", url: "", command: "", args: "" });
|
const [addMcpForm, setAddMcpForm] = useState({ name: "", url: "" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||||
@@ -374,7 +374,7 @@ export default function CoworkToolCard({
|
|||||||
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
|
<div key={p.name} className="flex items-center gap-2 px-2 py-1 bg-surface rounded border border-border">
|
||||||
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.name}</span>
|
<span className="text-xs font-medium min-w-0 truncate flex-shrink-0">{p.name}</span>
|
||||||
<span className="text-[8px] px-1 py-0.5 rounded bg-blue-500/10 text-blue-500 shrink-0">custom</span>
|
<span className="text-[8px] px-1 py-0.5 rounded bg-blue-500/10 text-blue-500 shrink-0">custom</span>
|
||||||
<span className="flex-1 text-[9px] text-text-muted truncate">{p.url || p.command}</span>
|
<span className="flex-1 text-[9px] text-text-muted truncate">{p.url}</span>
|
||||||
<button onClick={() => setCustomPlugins(customPlugins.filter((x) => x.name !== p.name))} className="shrink-0 hover:text-red-500 ml-auto">
|
<button onClick={() => setCustomPlugins(customPlugins.filter((x) => x.name !== p.name))} className="shrink-0 hover:text-red-500 ml-auto">
|
||||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -388,7 +388,7 @@ export default function CoworkToolCard({
|
|||||||
<button onClick={() => setMarketplaceOpen(true)} className="px-2 py-1 rounded border text-xs bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer whitespace-nowrap">
|
<button onClick={() => setMarketplaceOpen(true)} className="px-2 py-1 rounded border text-xs bg-primary/10 border-primary/40 text-primary hover:bg-primary/20 cursor-pointer whitespace-nowrap">
|
||||||
+ Browse
|
+ Browse
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setAddMcpForm({ type: "url", name: "", url: "", command: "", args: "" }); setAddMcpOpen(true); }} className="px-2 py-1 rounded border text-xs bg-surface border-border text-text-muted hover:border-primary hover:text-primary cursor-pointer whitespace-nowrap">
|
<button onClick={() => { setAddMcpForm({ name: "", url: "" }); setAddMcpOpen(true); }} className="px-2 py-1 rounded border text-xs bg-surface border-border text-text-muted hover:border-primary hover:text-primary cursor-pointer whitespace-nowrap">
|
||||||
+ Custom
|
+ Custom
|
||||||
</button>
|
</button>
|
||||||
<a href="https://mcp.so" target="_blank" rel="noopener noreferrer" className="text-[10px] text-text-muted hover:text-primary underline ml-auto">Find MCPs →</a>
|
<a href="https://mcp.so" target="_blank" rel="noopener noreferrer" className="text-[10px] text-text-muted hover:text-primary underline ml-auto">Find MCPs →</a>
|
||||||
@@ -554,17 +554,6 @@ export default function CoworkToolCard({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setAddMcpForm((f) => ({ ...f, type: "url" }))}
|
|
||||||
className={`flex-1 py-1.5 rounded border text-xs font-medium transition-colors ${addMcpForm.type === "url" ? "bg-primary/10 border-primary/40 text-primary" : "border-border text-text-muted hover:border-primary/40"}`}
|
|
||||||
>URL (SSE)</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setAddMcpForm((f) => ({ ...f, type: "cmd" }))}
|
|
||||||
className={`flex-1 py-1.5 rounded border text-xs font-medium transition-colors ${addMcpForm.type === "cmd" ? "bg-primary/10 border-primary/40 text-primary" : "border-border text-text-muted hover:border-primary/40"}`}
|
|
||||||
>Command (stdio)</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[11px] text-text-muted font-medium">Name</label>
|
<label className="text-[11px] text-text-muted font-medium">Name</label>
|
||||||
@@ -576,7 +565,6 @@ export default function CoworkToolCard({
|
|||||||
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{addMcpForm.type === "url" ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[11px] text-text-muted font-medium">SSE URL</label>
|
<label className="text-[11px] text-text-muted font-medium">SSE URL</label>
|
||||||
<input
|
<input
|
||||||
@@ -587,30 +575,6 @@ export default function CoworkToolCard({
|
|||||||
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-[11px] text-text-muted font-medium">Command</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="npx"
|
|
||||||
value={addMcpForm.command}
|
|
||||||
onChange={(e) => setAddMcpForm((f) => ({ ...f, command: e.target.value }))}
|
|
||||||
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-[11px] text-text-muted font-medium">Args <span className="font-normal">(comma-separated)</span></label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="-y, @some/mcp-package"
|
|
||||||
value={addMcpForm.args}
|
|
||||||
onChange={(e) => setAddMcpForm((f) => ({ ...f, args: e.target.value }))}
|
|
||||||
className="px-2 py-1.5 rounded border border-border bg-surface text-xs outline-none focus:border-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
@@ -618,15 +582,8 @@ export default function CoworkToolCard({
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const name = addMcpForm.name.trim();
|
const name = addMcpForm.name.trim();
|
||||||
if (!name) return;
|
if (!name || !addMcpForm.url.trim()) return;
|
||||||
if (addMcpForm.type === "url") {
|
|
||||||
if (!addMcpForm.url.trim()) return;
|
|
||||||
setCustomPlugins((prev) => [...prev.filter((x) => x.name !== name), { name, url: addMcpForm.url.trim(), transport: "sse", custom: true }]);
|
setCustomPlugins((prev) => [...prev.filter((x) => x.name !== name), { name, url: addMcpForm.url.trim(), transport: "sse", custom: true }]);
|
||||||
} else {
|
|
||||||
if (!addMcpForm.command.trim()) return;
|
|
||||||
const args = addMcpForm.args.split(",").map((a) => a.trim()).filter(Boolean);
|
|
||||||
setCustomPlugins((prev) => [...prev.filter((x) => x.name !== name), { name, command: addMcpForm.command.trim(), args, custom: true }]);
|
|
||||||
}
|
|
||||||
setAddMcpOpen(false);
|
setAddMcpOpen(false);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1.5 rounded bg-primary text-white text-xs font-medium hover:opacity-90 cursor-pointer"
|
className="px-3 py-1.5 rounded bg-primary text-white text-xs font-medium hover:opacity-90 cursor-pointer"
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import fs from "fs/promises";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, ALLOWED_MCP_COMMANDS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
|
import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins";
|
||||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||||
import { DATA_DIR } from "@/lib/dataDir";
|
|
||||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||||
|
|
||||||
const APP_PORT = UPDATER_CONFIG.appPort;
|
const APP_PORT = UPDATER_CONFIG.appPort;
|
||||||
@@ -180,17 +179,8 @@ const buildCustomEntries = (customPlugins) => {
|
|||||||
if (!Array.isArray(customPlugins)) return [];
|
if (!Array.isArray(customPlugins)) return [];
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const p of customPlugins) {
|
for (const p of customPlugins) {
|
||||||
if (!p?.name) continue;
|
if (!p?.name || !p?.url) continue;
|
||||||
if (p.url) {
|
|
||||||
out.push({ name: p.name, url: p.url, transport: p.transport || "sse", custom: true });
|
out.push({ name: p.name, url: p.url, transport: p.transport || "sse", custom: true });
|
||||||
} else if (p.command) {
|
|
||||||
out.push({
|
|
||||||
name: p.name,
|
|
||||||
url: `http://localhost:${APP_PORT}/api/mcp/${encodeURIComponent(p.name)}/sse`,
|
|
||||||
transport: "sse",
|
|
||||||
custom: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
@@ -311,8 +301,6 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
// Cowork disabled: spawns arbitrary processes (RCE risk).
|
|
||||||
return NextResponse.json({ error: "Cowork is disabled" }, { status: 403 });
|
|
||||||
try {
|
try {
|
||||||
const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json();
|
const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json();
|
||||||
|
|
||||||
@@ -327,29 +315,8 @@ export async function POST(request) {
|
|||||||
// Respect empty array (user toggled all off); fallback to defaults only when undefined.
|
// Respect empty array (user toggled all off); fallback to defaults only when undefined.
|
||||||
const pluginsArray = Array.isArray(plugins) ? plugins : DEFAULT_PLUGINS;
|
const pluginsArray = Array.isArray(plugins) ? plugins : DEFAULT_PLUGINS;
|
||||||
const localPluginNames = Array.isArray(localPlugins) ? localPlugins : [];
|
const localPluginNames = Array.isArray(localPlugins) ? localPlugins : [];
|
||||||
const customPluginsArray = Array.isArray(customPlugins) ? customPlugins : [];
|
// Only URL-based custom plugins allowed (no stdio command spawning).
|
||||||
|
const customPluginsArray = (Array.isArray(customPlugins) ? customPlugins : []).filter((p) => p?.url);
|
||||||
// Register custom stdio plugins into bridge + persist for restart survival.
|
|
||||||
if (customPluginsArray.length > 0) {
|
|
||||||
const { registerCustomPlugin } = require("@/lib/mcp/stdioSseBridge");
|
|
||||||
const stdioCustoms = customPluginsArray
|
|
||||||
.filter((p) => p && typeof p.command === "string" && p.command.trim())
|
|
||||||
.filter((p) => ALLOWED_MCP_COMMANDS.has(path.basename(p.command)))
|
|
||||||
.map((p) => ({
|
|
||||||
name: String(p.name || "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64),
|
|
||||||
command: p.command,
|
|
||||||
args: Array.isArray(p.args) ? p.args.map(String) : [],
|
|
||||||
}))
|
|
||||||
.filter((p) => p.name);
|
|
||||||
for (const p of stdioCustoms) {
|
|
||||||
try { registerCustomPlugin(p); } catch { /* skip invalid */ }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const dir = path.join(DATA_DIR, "mcp");
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(dir, "customPlugins.json"), JSON.stringify(stdioCustoms, null, 2));
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const bridgeEntries = await injectAuthHeaders(buildLocalBridgeEntries(localPluginNames));
|
const bridgeEntries = await injectAuthHeaders(buildLocalBridgeEntries(localPluginNames));
|
||||||
const customEntries = await injectAuthHeaders(buildCustomEntries(customPluginsArray));
|
const customEntries = await injectAuthHeaders(buildCustomEntries(customPluginsArray));
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ export const runtime = "nodejs";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function POST(request, { params }) {
|
export async function POST(request, { params }) {
|
||||||
// Cowork disabled: MCP stdio bridge spawns arbitrary processes (RCE risk).
|
|
||||||
return NextResponse.json({ error: "Cowork is disabled" }, { status: 403 });
|
|
||||||
const { plugin } = await params;
|
const { plugin } = await params;
|
||||||
if (!findPlugin(plugin)) {
|
if (!findPlugin(plugin)) {
|
||||||
return NextResponse.json({ error: `Unknown plugin: ${plugin}` }, { status: 404 });
|
return NextResponse.json({ error: `Unknown plugin: ${plugin}` }, { status: 404 });
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ export const runtime = "nodejs";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(request, { params }) {
|
export async function GET(request, { params }) {
|
||||||
// Cowork disabled: MCP stdio bridge spawns arbitrary processes (RCE risk).
|
|
||||||
return new Response("Cowork is disabled", { status: 403 });
|
|
||||||
const { plugin } = await params;
|
const { plugin } = await params;
|
||||||
if (!findPlugin(plugin)) {
|
if (!findPlugin(plugin)) {
|
||||||
return new Response(`Unknown plugin: ${plugin}`, { status: 404 });
|
return new Response(`Unknown plugin: ${plugin}`, { status: 404 });
|
||||||
|
|||||||
@@ -2,13 +2,8 @@
|
|||||||
// broadcasts JSON-RPC frames over SSE, accepts client messages via HTTP POST.
|
// broadcasts JSON-RPC frames over SSE, accepts client messages via HTTP POST.
|
||||||
|
|
||||||
const { spawn } = require("child_process");
|
const { spawn } = require("child_process");
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const { LOCAL_STDIO_PLUGINS, ALLOWED_MCP_COMMANDS } = require("@/shared/constants/coworkPlugins");
|
const { LOCAL_STDIO_PLUGINS } = require("@/shared/constants/coworkPlugins");
|
||||||
const { DATA_DIR } = require("@/lib/dataDir");
|
|
||||||
|
|
||||||
const CUSTOM_FILE = path.join(DATA_DIR, "mcp", "customPlugins.json");
|
|
||||||
|
|
||||||
const G_KEY = "__9routerMcpBridges";
|
const G_KEY = "__9routerMcpBridges";
|
||||||
const MAX_TEXT_CHARS = 50000;
|
const MAX_TEXT_CHARS = 50000;
|
||||||
@@ -106,33 +101,9 @@ const getStore = () => {
|
|||||||
return globalThis[G_KEY];
|
return globalThis[G_KEY];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCustomStore = () => {
|
// Only preset stdio plugins may spawn. No user-defined commands (RCE prevention).
|
||||||
if (!globalThis.__9routerCustomPlugins) globalThis.__9routerCustomPlugins = new Map();
|
|
||||||
return globalThis.__9routerCustomPlugins;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isAllowedCommand(cmd) {
|
|
||||||
const bin = path.basename(String(cmd || ""));
|
|
||||||
return ALLOWED_MCP_COMMANDS.has(bin);
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerCustomPlugin(def) {
|
|
||||||
if (!isAllowedCommand(def?.command)) {
|
|
||||||
throw new Error(`Blocked: command '${def?.command}' not in MCP allowlist`);
|
|
||||||
}
|
|
||||||
getCustomStore().set(def.name, def);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findPlugin(name) {
|
function findPlugin(name) {
|
||||||
const fromMem = getCustomStore().get(name) || LOCAL_STDIO_PLUGINS.find((p) => p.name === name);
|
return LOCAL_STDIO_PLUGINS.find((p) => p.name === name) || null;
|
||||||
if (fromMem) return fromMem;
|
|
||||||
// Lazy-load custom plugins from disk (survives app restart); re-validate allowlist.
|
|
||||||
try {
|
|
||||||
const list = JSON.parse(fs.readFileSync(CUSTOM_FILE, "utf-8"));
|
|
||||||
const def = Array.isArray(list) ? list.find((p) => p.name === name && p.command) : null;
|
|
||||||
if (def && isAllowedCommand(def.command)) { getCustomStore().set(def.name, def); return def; }
|
|
||||||
} catch { /* file missing or invalid */ }
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrSpawn(name) {
|
function getOrSpawn(name) {
|
||||||
@@ -195,4 +166,4 @@ function isRunning(name) {
|
|||||||
return !!(entry?.proc && !entry.proc.killed && entry.proc.exitCode === null);
|
return !!(entry?.proc && !entry.proc.killed && entry.proc.exitCode === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin, registerCustomPlugin };
|
module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin };
|
||||||
|
|||||||
@@ -128,15 +128,14 @@ export const CLI_TOOLS = {
|
|||||||
description: "OpenCode AI Terminal Assistant",
|
description: "OpenCode AI Terminal Assistant",
|
||||||
configType: "custom",
|
configType: "custom",
|
||||||
},
|
},
|
||||||
// Cowork disabled: spawns arbitrary processes (RCE risk). Hidden from CLI tools UI.
|
cowork: {
|
||||||
// cowork: {
|
id: "cowork",
|
||||||
// id: "cowork",
|
name: "Claude Cowork",
|
||||||
// name: "Claude Cowork",
|
image: "/providers/claude.png",
|
||||||
// image: "/providers/claude.png",
|
color: "#D97757",
|
||||||
// color: "#D97757",
|
description: "Claude Desktop Cowork (third-party inference)",
|
||||||
// description: "Claude Desktop Cowork (third-party inference)",
|
configType: "custom",
|
||||||
// configType: "custom",
|
},
|
||||||
// },
|
|
||||||
hermes: {
|
hermes: {
|
||||||
id: "hermes",
|
id: "hermes",
|
||||||
name: "Hermes Agent",
|
name: "Hermes Agent",
|
||||||
|
|||||||
@@ -69,7 +69,4 @@ function buildManagedMcpServers(plugins) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allowlist of executables that may be spawned for custom stdio MCP plugins.
|
module.exports = { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers };
|
||||||
const ALLOWED_MCP_COMMANDS = new Set(["npx", "node", "uvx", "python", "python3", "bunx", "bun"]);
|
|
||||||
|
|
||||||
module.exports = { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, ALLOWED_MCP_COMMANDS, buildManagedMcpServers };
|
|
||||||
|
|||||||
Reference in New Issue
Block a user