| "use server"; |
|
|
| import { NextResponse } from "next/server"; |
| import fs from "fs/promises"; |
| import path from "path"; |
| import os from "os"; |
| import crypto from "crypto"; |
| import { DEFAULT_PLUGINS, LOCAL_STDIO_PLUGINS, buildManagedMcpServers } from "@/shared/constants/coworkPlugins"; |
| import { UPDATER_CONFIG } from "@/shared/constants/config"; |
| import { getConsistentMachineId } from "@/shared/utils/machineId"; |
|
|
| const APP_PORT = UPDATER_CONFIG.appPort; |
| const CLI_TOKEN_HEADER = "x-9r-cli-token"; |
| const CLI_TOKEN_SALT = "9r-cli-auth"; |
| const LOCAL_MCP_PREFIX = `http://localhost:${APP_PORT}/api/mcp/`; |
|
|
| let cachedCliToken = null; |
| const getCliToken = async () => { |
| if (!cachedCliToken) cachedCliToken = await getConsistentMachineId(CLI_TOKEN_SALT); |
| return cachedCliToken; |
| }; |
|
|
| |
| const injectAuthHeaders = async (entries) => { |
| const token = await getCliToken(); |
| for (const e of entries) { |
| if (typeof e?.url === "string" && e.url.startsWith(LOCAL_MCP_PREFIX)) { |
| e.headers = { ...(e.headers || {}), [CLI_TOKEN_HEADER]: token }; |
| } |
| } |
| return entries; |
| }; |
|
|
| const PROVIDER = "gateway"; |
|
|
| |
| const SECURITY_RELAX = { |
| coworkEgressAllowedHosts: ["*"], |
| disabledBuiltinTools: [], |
| isLocalDevMcpEnabled: true, |
| isDesktopExtensionEnabled: true, |
| isDesktopExtensionDirectoryEnabled: true, |
| isDesktopExtensionSignatureRequired: false, |
| isClaudeCodeForDesktopEnabled: true, |
| disableEssentialTelemetry: true, |
| disableNonessentialTelemetry: true, |
| disableNonessentialServices: true, |
| }; |
|
|
| |
| |
|
|
| const getCandidateRoots = () => { |
| if (os.platform() === "darwin") { |
| const base = path.join(os.homedir(), "Library", "Application Support"); |
| return [path.join(base, "Claude-3p"), path.join(base, "Claude")]; |
| } |
| if (os.platform() === "win32") { |
| const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); |
| const roaming = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"); |
| return [ |
| path.join(localApp, "Claude-3p"), |
| path.join(roaming, "Claude-3p"), |
| path.join(localApp, "Claude"), |
| path.join(roaming, "Claude"), |
| ]; |
| } |
| return [ |
| path.join(os.homedir(), ".config", "Claude-3p"), |
| path.join(os.homedir(), ".config", "Claude"), |
| ]; |
| }; |
|
|
| const getAppInstallPaths = () => { |
| if (os.platform() === "darwin") { |
| return ["/Applications/Claude.app", path.join(os.homedir(), "Applications", "Claude.app")]; |
| } |
| if (os.platform() === "win32") { |
| const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); |
| const programFiles = process.env["ProgramFiles"] || "C:\\Program Files"; |
| return [ |
| path.join(localApp, "AnthropicClaude"), |
| path.join(programFiles, "Claude"), |
| path.join(programFiles, "AnthropicClaude"), |
| ]; |
| } |
| return []; |
| }; |
|
|
| const resolveAppRootForRead = async () => { |
| const candidates = getCandidateRoots(); |
| for (const dir of candidates) { |
| try { |
| await fs.access(path.join(dir, "configLibrary")); |
| return dir; |
| } catch { } |
| } |
| return candidates[0]; |
| }; |
|
|
| const getWriteRoot = () => getCandidateRoots()[0]; |
| const getConfigDir = async () => path.join(await resolveAppRootForRead(), "configLibrary"); |
| const getWriteConfigDir = () => path.join(getWriteRoot(), "configLibrary"); |
| const getMetaPath = async () => path.join(await getConfigDir(), "_meta.json"); |
| const getWriteMetaPath = () => path.join(getWriteConfigDir(), "_meta.json"); |
|
|
| const get1pRoot = () => { |
| if (os.platform() === "darwin") return path.join(os.homedir(), "Library", "Application Support", "Claude"); |
| if (os.platform() === "win32") { |
| const roaming = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"); |
| return path.join(roaming, "Claude"); |
| } |
| return path.join(os.homedir(), ".config", "Claude"); |
| }; |
|
|
| const get1pConfigPath = () => path.join(get1pRoot(), "claude_desktop_config.json"); |
|
|
| const read1pConfig = async () => { |
| try { return JSON.parse(await fs.readFile(get1pConfigPath(), "utf-8")) || {}; } |
| catch (error) { |
| if (error.code === "ENOENT") return {}; |
| throw error; |
| } |
| }; |
|
|
| const write1pConfig = async (cfg) => { |
| await fs.mkdir(get1pRoot(), { recursive: true }); |
| await fs.writeFile(get1pConfigPath(), JSON.stringify(cfg, null, 2)); |
| }; |
|
|
| const bootstrapDeploymentMode = async () => { |
| const cfg = await read1pConfig(); |
| if (cfg.deploymentMode === "3p") return false; |
| cfg.deploymentMode = "3p"; |
| await write1pConfig(cfg); |
| return true; |
| }; |
|
|
| |
| const cleanup1pLegacy = async () => { |
| const cfg = await read1pConfig(); |
| if (!cfg.mcpServers || typeof cfg.mcpServers !== "object") return; |
| const managedNames = new Set(LOCAL_STDIO_PLUGINS.map((p) => p.name)); |
| for (const k of Object.keys(cfg.mcpServers)) { |
| if (managedNames.has(k)) delete cfg.mcpServers[k]; |
| } |
| if (Object.keys(cfg.mcpServers).length === 0) delete cfg.mcpServers; |
| await write1pConfig(cfg); |
| }; |
|
|
| |
| const buildLocalBridgeEntries = (localPluginNames) => { |
| const names = Array.isArray(localPluginNames) ? localPluginNames : []; |
| const out = []; |
| for (const n of names) { |
| const def = LOCAL_STDIO_PLUGINS.find((p) => p.name === n); |
| if (!def) continue; |
| const entry = { |
| name: def.name, |
| url: `http://localhost:${APP_PORT}/api/mcp/${def.name}/sse`, |
| transport: "sse", |
| }; |
| if (Array.isArray(def.toolNames) && def.toolNames.length > 0) { |
| const prefix = `${def.name}-`; |
| const policy = {}; |
| for (const t of def.toolNames) { |
| policy[t] = "allow"; |
| policy[`${prefix}${t}`] = "allow"; |
| } |
| entry.toolPolicy = policy; |
| } |
| out.push(entry); |
| } |
| return out; |
| }; |
|
|
| |
| const buildCustomEntries = (customPlugins) => { |
| if (!Array.isArray(customPlugins)) return []; |
| const out = []; |
| for (const p of customPlugins) { |
| if (!p?.name || !p?.url) continue; |
| out.push({ name: p.name, url: p.url, transport: p.transport || "sse", custom: true }); |
| } |
| return out; |
| }; |
|
|
| const checkInstalled = async () => { |
| for (const dir of [...getCandidateRoots(), ...getAppInstallPaths()]) { |
| try { await fs.access(dir); return true; } catch { } |
| } |
| return false; |
| }; |
|
|
| const readJson = async (filePath) => { |
| try { return JSON.parse(await fs.readFile(filePath, "utf-8")); } |
| catch (error) { |
| if (error.code === "ENOENT") return null; |
| throw error; |
| } |
| }; |
|
|
| const ensureMeta = async () => { |
| const writeMetaPath = getWriteMetaPath(); |
| let meta = await readJson(writeMetaPath); |
| if (!meta || !meta.appliedId) { |
| const existingRead = await readJson(await getMetaPath()); |
| if (existingRead?.appliedId) { |
| meta = existingRead; |
| } else { |
| const newId = crypto.randomUUID(); |
| meta = { appliedId: newId, entries: [{ id: newId, name: "Default" }] }; |
| } |
| await fs.mkdir(getWriteConfigDir(), { recursive: true }); |
| await fs.writeFile(writeMetaPath, JSON.stringify(meta, null, 2)); |
| } |
| return meta; |
| }; |
|
|
| |
| async function writeSkipApprovals(managedServers) { |
| const cfgPath = path.join(getWriteRoot(), "config.json"); |
| let cfg = {}; |
| try { cfg = JSON.parse(await fs.readFile(cfgPath, "utf-8")) || {}; } |
| catch (e) { if (e.code !== "ENOENT") return { error: e.code }; } |
| const skip = {}; |
| for (const srv of managedServers) { |
| if (srv?.name) skip[srv.name] = true; |
| } |
| cfg.operonSkipMcpApprovals = skip; |
| await fs.mkdir(getWriteRoot(), { recursive: true }); |
| await fs.writeFile(cfgPath, JSON.stringify(cfg, null, 2)); |
| return { written: Object.keys(skip).length }; |
| } |
|
|
| export async function GET() { |
| try { |
| const installed = await checkInstalled(); |
| if (!installed) { |
| return NextResponse.json({ installed: false, config: null, message: "Claude Desktop (Cowork mode) not detected" }); |
| } |
| const meta = await readJson(await getMetaPath()); |
| const appliedId = meta?.appliedId || null; |
| const configDir = await getConfigDir(); |
| const configPath = appliedId ? path.join(configDir, `${appliedId}.json`) : null; |
| const config = configPath ? await readJson(configPath) : null; |
|
|
| const baseUrl = config?.inferenceGatewayBaseUrl || null; |
| const models = Array.isArray(config?.inferenceModels) |
| ? config.inferenceModels.map((m) => (typeof m === "string" ? m : m?.name)).filter(Boolean) |
| : []; |
| const managedMcp = Array.isArray(config?.managedMcpServers) ? config.managedMcpServers : []; |
| const has9Router = !!(config?.inferenceProvider === PROVIDER && baseUrl); |
|
|
| |
| const stdioNames = new Set(LOCAL_STDIO_PLUGINS.map((p) => p.name)); |
| const activeLocalNames = managedMcp |
| .filter((m) => stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/")) |
| .map((m) => m.name); |
|
|
| |
| const activeCustomPlugins = managedMcp |
| .filter((m) => m.custom || (!stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/"))) |
| .map((m) => ({ name: m.name, url: m.url, transport: m.transport, custom: true })); |
|
|
| return NextResponse.json({ |
| installed: true, |
| config, |
| has9Router, |
| configPath, |
| cowork: { |
| appliedId, |
| baseUrl, |
| models, |
| provider: config?.inferenceProvider || null, |
| plugins: managedMcp.filter((m) => !m.custom && !(stdioNames.has(m.name) && typeof m.url === "string" && m.url.includes("/api/mcp/"))).map((m) => { |
| |
| const keys = m.toolPolicy ? Object.keys(m.toolPolicy) : []; |
| const prefix = `${m.name}-`; |
| const bare = new Set(); |
| for (const k of keys) { |
| let t = k; |
| while (t.startsWith(prefix)) t = t.slice(prefix.length); |
| bare.add(t); |
| } |
| |
| const def = DEFAULT_PLUGINS.find((d) => d.name === m.name); |
| const toolNames = def && Array.isArray(def.toolNames) ? def.toolNames : Array.from(bare); |
| return { name: m.name, url: m.url, transport: m.transport, oauth: !!m.oauth, toolNames }; |
| }), |
| localPlugins: activeLocalNames, |
| customPlugins: activeCustomPlugins, |
| }, |
| defaultPlugins: DEFAULT_PLUGINS, |
| localStdioPlugins: LOCAL_STDIO_PLUGINS, |
| }); |
| } catch (error) { |
| console.log("Error reading cowork settings:", error); |
| return NextResponse.json({ error: "Failed to read cowork settings" }, { status: 500 }); |
| } |
| } |
|
|
| export async function POST(request) { |
| try { |
| const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json(); |
|
|
| if (!baseUrl || !apiKey) { |
| return NextResponse.json({ error: "baseUrl and apiKey are required" }, { status: 400 }); |
| } |
| const modelsArray = Array.isArray(models) ? models.filter((m) => typeof m === "string" && m.trim()) : []; |
| if (modelsArray.length === 0) { |
| return NextResponse.json({ error: "At least one model is required" }, { status: 400 }); |
| } |
|
|
| |
| const pluginsArray = Array.isArray(plugins) ? plugins : DEFAULT_PLUGINS; |
| const localPluginNames = Array.isArray(localPlugins) ? localPlugins : []; |
| |
| const customPluginsArray = (Array.isArray(customPlugins) ? customPlugins : []).filter((p) => p?.url); |
|
|
| const bridgeEntries = await injectAuthHeaders(buildLocalBridgeEntries(localPluginNames)); |
| const customEntries = await injectAuthHeaders(buildCustomEntries(customPluginsArray)); |
| const managedMcpServers = [...buildManagedMcpServers(pluginsArray), ...bridgeEntries, ...customEntries]; |
|
|
| const bootstrapped = await bootstrapDeploymentMode(); |
| const meta = await ensureMeta(); |
| const configPath = path.join(getWriteConfigDir(), `${meta.appliedId}.json`); |
|
|
| const newConfig = { |
| ...SECURITY_RELAX, |
| inferenceProvider: PROVIDER, |
| inferenceGatewayBaseUrl: baseUrl, |
| inferenceGatewayApiKey: apiKey, |
| inferenceModels: modelsArray.map((name) => ({ name })), |
| }; |
| if (managedMcpServers.length > 0) newConfig.managedMcpServers = managedMcpServers; |
|
|
| await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2)); |
|
|
| let skipResult = null; |
| try { skipResult = await writeSkipApprovals(managedMcpServers); } catch (e) { skipResult = { error: e.message }; } |
|
|
| |
| let localMcpResult = { applied: localPluginNames, via: "3p-sse-bridge" }; |
| try { await cleanup1pLegacy(); } catch { } |
|
|
| return NextResponse.json({ |
| success: true, |
| bootstrapped, |
| message: bootstrapped |
| ? "Cowork enabled (3p mode set). Quit & reopen Claude Desktop." |
| : "Cowork settings applied. Quit & reopen Claude Desktop.", |
| configPath, |
| skipApprovals: skipResult, |
| localMcp: localMcpResult, |
| }); |
| } catch (error) { |
| console.log("Error applying cowork settings:", error); |
| return NextResponse.json({ error: "Failed to apply cowork settings" }, { status: 500 }); |
| } |
| } |
|
|
| export async function DELETE() { |
| try { |
| const meta = await readJson(await getMetaPath()); |
| if (!meta?.appliedId) { |
| return NextResponse.json({ success: true, message: "No active config to reset" }); |
| } |
| const configPath = path.join(await getConfigDir(), `${meta.appliedId}.json`); |
| try { await fs.writeFile(configPath, JSON.stringify({}, null, 2)); } |
| catch (error) { if (error.code !== "ENOENT") throw error; } |
| try { await writeSkipApprovals([]); } catch { } |
| try { await cleanup1pLegacy(); } catch { } |
| return NextResponse.json({ success: true, message: "Cowork config reset" }); |
| } catch (error) { |
| console.log("Error resetting cowork settings:", error); |
| return NextResponse.json({ error: "Failed to reset cowork settings" }, { status: 500 }); |
| } |
| } |
|
|