diff --git a/packages/server/src/server/routes/ollama.ts b/packages/server/src/server/routes/ollama.ts index 258cd4a..d246ac1 100644 --- a/packages/server/src/server/routes/ollama.ts +++ b/packages/server/src/server/routes/ollama.ts @@ -9,10 +9,13 @@ import { import { Logger } from "../../logger" import fs from "fs" import path from "path" -import { getUserIntegrationsDir } from "../../user-data" +import { getUserIntegrationsDir, getUserIdFromRequest } from "../../user-context" -const CONFIG_DIR = getUserIntegrationsDir() -const CONFIG_FILE = path.join(CONFIG_DIR, "ollama-config.json") +// Helper to get config file path for a user +function getConfigFile(userId?: string | null): string { + const configDir = getUserIntegrationsDir(userId || undefined) + return path.join(configDir, "ollama-config.json") +} interface OllamaRouteDeps { logger: Logger @@ -26,7 +29,8 @@ export async function registerOllamaRoutes( app.get('/api/ollama/config', async (request, reply) => { try { - const config = getOllamaConfig() + const userId = getUserIdFromRequest(request) + const config = getOllamaConfig(userId) return { config: { ...config, apiKey: config.apiKey ? '***' : undefined } } } catch (error) { logger.error({ error }, "Failed to get Ollama config") @@ -48,9 +52,10 @@ export async function registerOllamaRoutes( } }, async (request, reply) => { try { + const userId = getUserIdFromRequest(request) const { enabled, apiKey, endpoint } = request.body as any - updateOllamaConfig({ enabled, apiKey, endpoint }) - logger.info("Ollama Cloud configuration updated") + updateOllamaConfig({ enabled, apiKey, endpoint }, userId) + logger.info({ userId }, "Ollama Cloud configuration updated for user") return { success: true, config: { enabled, endpoint, apiKey: apiKey ? '***' : undefined } } } catch (error) { logger.error({ error }, "Failed to update Ollama config") @@ -60,7 +65,8 @@ export async function registerOllamaRoutes( app.post('/api/ollama/test', async (request, reply) => { try { - const config = getOllamaConfig() + const userId = getUserIdFromRequest(request) + const config = getOllamaConfig(userId) if (!config.enabled) { return reply.status(400).send({ error: "Ollama Cloud is not enabled" }) } @@ -556,24 +562,27 @@ export async function registerOllamaRoutes( logger.info("Ollama Cloud routes registered") } -function getOllamaConfig(): OllamaCloudConfig { +function getOllamaConfig(userId?: string | null): OllamaCloudConfig { + const configFile = getConfigFile(userId) try { - if (!fs.existsSync(CONFIG_FILE)) { + if (!fs.existsSync(configFile)) { return { enabled: false, endpoint: "https://ollama.com" } } - const data = fs.readFileSync(CONFIG_FILE, 'utf-8') + const data = fs.readFileSync(configFile, 'utf-8') return JSON.parse(data) } catch { return { enabled: false, endpoint: "https://ollama.com" } } } -function updateOllamaConfig(config: Partial): void { +function updateOllamaConfig(config: Partial, userId?: string | null): void { + const configFile = getConfigFile(userId) + const configDir = getUserIntegrationsDir(userId || undefined) try { - if (!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }) + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }) } - const current = getOllamaConfig() + const current = getOllamaConfig(userId) // Only update apiKey if a new non-empty value is provided const updated = { @@ -583,8 +592,8 @@ function updateOllamaConfig(config: Partial): void { apiKey: config.apiKey || current.apiKey } - fs.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2)) - console.log(`[Ollama] Config saved: enabled=${updated.enabled}, endpoint=${updated.endpoint}, hasApiKey=${!!updated.apiKey}`) + fs.writeFileSync(configFile, JSON.stringify(updated, null, 2)) + console.log(`[Ollama] Config saved for user ${userId || "default"}: enabled=${updated.enabled}, endpoint=${updated.endpoint}, hasApiKey=${!!updated.apiKey}`) } catch (error) { console.error("Failed to save Ollama config:", error) } diff --git a/packages/server/src/server/routes/zai.ts b/packages/server/src/server/routes/zai.ts index 94402c7..a342e5e 100644 --- a/packages/server/src/server/routes/zai.ts +++ b/packages/server/src/server/routes/zai.ts @@ -1,9 +1,9 @@ -import { FastifyInstance } from "fastify" +import { FastifyInstance, FastifyRequest } from "fastify" import { ZAIClient, ZAI_MODELS, type ZAIConfig, type ZAIChatRequest, type ZAIMessage } from "../../integrations/zai-api" import { Logger } from "../../logger" import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" import { join } from "path" -import { getUserIntegrationsDir } from "../../user-data" +import { getUserIntegrationsDir, getUserIdFromRequest } from "../../user-context" import { CORE_TOOLS, executeTools, type ToolCall, type ToolResult } from "../../tools/executor" import { getMcpManager } from "../../mcp/client" @@ -11,27 +11,27 @@ interface ZAIRouteDeps { logger: Logger } -const CONFIG_DIR = getUserIntegrationsDir() -const CONFIG_FILE = join(CONFIG_DIR, "zai-config.json") - // Maximum number of tool execution loops to prevent infinite recursion const MAX_TOOL_LOOPS = 10 +// Helper to get config file path for a user +function getConfigFile(userId?: string | null): string { + const configDir = getUserIntegrationsDir(userId || undefined) + return join(configDir, "zai-config.json") +} + export async function registerZAIRoutes( app: FastifyInstance, deps: ZAIRouteDeps ) { const logger = deps.logger.child({ component: "zai-routes" }) - // Ensure config directory exists - if (!existsSync(CONFIG_DIR)) { - mkdirSync(CONFIG_DIR, { recursive: true }) - } - - // Get Z.AI configuration + // Get Z.AI configuration (per-user) app.get('/api/zai/config', async (request, reply) => { try { - const config = getZAIConfig() + const userId = getUserIdFromRequest(request) + const config = getZAIConfig(userId) + logger.debug({ userId }, "Getting Z.AI config for user") return { config: { ...config, apiKey: config.apiKey ? '***' : undefined } } } catch (error) { logger.error({ error }, "Failed to get Z.AI config") @@ -39,12 +39,13 @@ export async function registerZAIRoutes( } }) - // Update Z.AI configuration + // Update Z.AI configuration (per-user) app.post('/api/zai/config', async (request, reply) => { try { + const userId = getUserIdFromRequest(request) const { enabled, apiKey, endpoint } = request.body as Partial - updateZAIConfig({ enabled, apiKey, endpoint }) - logger.info("Z.AI configuration updated") + updateZAIConfig({ enabled, apiKey, endpoint }, userId) + logger.info({ userId }, "Z.AI configuration updated for user") return { success: true, config: { enabled, endpoint, apiKey: apiKey ? '***' : undefined } } } catch (error) { logger.error({ error }, "Failed to update Z.AI config") @@ -52,10 +53,11 @@ export async function registerZAIRoutes( } }) - // Test Z.AI connection + // Test Z.AI connection (per-user) app.post('/api/zai/test', async (request, reply) => { try { - const config = getZAIConfig() + const userId = getUserIdFromRequest(request) + const config = getZAIConfig(userId) if (!config.enabled) { return reply.status(400).send({ error: "Z.AI is not enabled" }) } @@ -80,10 +82,11 @@ export async function registerZAIRoutes( } }) - // Chat completion endpoint WITH MCP TOOL SUPPORT + // Chat completion endpoint WITH MCP TOOL SUPPORT (per-user) app.post('/api/zai/chat', async (request, reply) => { try { - const config = getZAIConfig() + const userId = getUserIdFromRequest(request) + const config = getZAIConfig(userId) if (!config.enabled) { return reply.status(400).send({ error: "Z.AI is not enabled" }) } @@ -348,11 +351,12 @@ async function chatWithToolLoop( return lastResponse } -function getZAIConfig(): ZAIConfig { +function getZAIConfig(userId?: string | null): ZAIConfig { + const configFile = getConfigFile(userId) try { - console.log(`[Z.AI] Looking for config at: ${CONFIG_FILE}`) - if (existsSync(CONFIG_FILE)) { - const data = readFileSync(CONFIG_FILE, 'utf-8') + console.log(`[Z.AI] Looking for config at: ${configFile} (user: ${userId || "default"})`) + if (existsSync(configFile)) { + const data = readFileSync(configFile, 'utf-8') const parsed = JSON.parse(data) console.log(`[Z.AI] Config loaded from file, enabled: ${parsed.enabled}`) return parsed @@ -365,24 +369,27 @@ function getZAIConfig(): ZAIConfig { } } -function updateZAIConfig(config: Partial): void { +function updateZAIConfig(config: Partial, userId?: string | null): void { + const configFile = getConfigFile(userId) + const configDir = getUserIntegrationsDir(userId || undefined) + // Ensure directory exists with proper error handling try { - if (!existsSync(CONFIG_DIR)) { - console.log(`[Z.AI] Creating config directory: ${CONFIG_DIR}`) - mkdirSync(CONFIG_DIR, { recursive: true }) + if (!existsSync(configDir)) { + console.log(`[Z.AI] Creating config directory: ${configDir}`) + mkdirSync(configDir, { recursive: true }) } } catch (mkdirError) { console.error(`[Z.AI] Failed to create config directory:`, mkdirError) throw new Error(`Failed to create config directory: ${mkdirError}`) } - const current = getZAIConfig() + const current = getZAIConfig(userId) const updated = { ...current, ...config } try { - console.log(`[Z.AI] Writing config to: ${CONFIG_FILE}`) - writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2), 'utf-8') + console.log(`[Z.AI] Writing config to: ${configFile} (user: ${userId || "default"})`) + writeFileSync(configFile, JSON.stringify(updated, null, 2), 'utf-8') console.log(`[Z.AI] Config saved successfully`) } catch (writeError) { console.error(`[Z.AI] Failed to write config file:`, writeError) diff --git a/packages/server/src/user-context.ts b/packages/server/src/user-context.ts new file mode 100644 index 0000000..64094f9 --- /dev/null +++ b/packages/server/src/user-context.ts @@ -0,0 +1,101 @@ +/** + * User Context Module + * Manages the active user context for per-user config isolation + */ + +import path from "path" +import os from "os" +import { existsSync, mkdirSync } from "fs" + +const CONFIG_ROOT = path.join(os.homedir(), ".config", "codenomad") +const USERS_ROOT = path.join(CONFIG_ROOT, "users") + +// Active user ID (set by the main process or HTTP header) +let activeUserId: string | null = null + +/** + * Set the active user ID + */ +export function setActiveUserId(userId: string | null): void { + activeUserId = userId + console.log(`[UserContext] Active user set to: ${userId || "(none)"}`) +} + +/** + * Get the active user ID + */ +export function getActiveUserId(): string | null { + return activeUserId +} + +/** + * Get the data root for a specific user + * Falls back to global config if no user is set + */ +export function getUserDataRoot(userId?: string): string { + const effectiveUserId = userId || activeUserId + + if (effectiveUserId) { + const userDir = path.join(USERS_ROOT, effectiveUserId) + return userDir + } + + // Prioritize environment variable if set (from Electron) + const override = process.env.CODENOMAD_USER_DIR + if (override && override.trim().length > 0) { + return path.resolve(override) + } + + // Fallback to global config root + return CONFIG_ROOT +} + +/** + * Get the integrations directory for the current or specified user + */ +export function getUserIntegrationsDir(userId?: string): string { + const userRoot = getUserDataRoot(userId) + const integrationsDir = path.join(userRoot, "integrations") + + // Ensure directory exists + if (!existsSync(integrationsDir)) { + try { + mkdirSync(integrationsDir, { recursive: true }) + console.log(`[UserContext] Created integrations dir: ${integrationsDir}`) + } catch (e) { + console.error(`[UserContext] Failed to create integrations dir:`, e) + } + } + + return integrationsDir +} + +/** + * Get the instances directory for the current or specified user + */ +export function getUserInstancesDir(userId?: string): string { + const userRoot = getUserDataRoot(userId) + return path.join(userRoot, "instances") +} + +/** + * Get the config file path for a specific integration + */ +export function getIntegrationConfigPath(integrationId: string, userId?: string): string { + const integrationsDir = getUserIntegrationsDir(userId) + return path.join(integrationsDir, `${integrationId}-config.json`) +} + +/** + * Extract user ID from request headers + */ +export function getUserIdFromRequest(request: { headers?: Record }): string | null { + const header = request.headers?.["x-user-id"] + if (typeof header === "string" && header.length > 0) { + return header + } + if (Array.isArray(header) && header.length > 0) { + return header[0] + } + return activeUserId +} diff --git a/packages/server/src/user-data.ts b/packages/server/src/user-data.ts index a46d0ce..cfdc475 100644 --- a/packages/server/src/user-data.ts +++ b/packages/server/src/user-data.ts @@ -1,14 +1,8 @@ -import os from "os" +import { getUserDataRoot as getRoot, getUserInstancesDir as getInstances, getUserIntegrationsDir as getIntegrations } from "./user-context" import path from "path" -const DEFAULT_ROOT = path.join(os.homedir(), ".config", "codenomad") - export function getUserDataRoot(): string { - const override = process.env.CODENOMAD_USER_DIR - if (override && override.trim().length > 0) { - return path.resolve(override) - } - return DEFAULT_ROOT + return getRoot() } export function getUserConfigPath(): string { @@ -16,11 +10,11 @@ export function getUserConfigPath(): string { } export function getUserInstancesDir(): string { - return path.join(getUserDataRoot(), "instances") + return getInstances() } export function getUserIntegrationsDir(): string { - return path.join(getUserDataRoot(), "integrations") + return getIntegrations() } export function getOpencodeWorkspacesRoot(): string { diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index d837ce1..bbcd360 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -100,6 +100,11 @@ const App: Component = () => { }) onMount(() => { + // Initialize user context from Electron IPC + import("./lib/user-context").then(({ initializeUserContext }) => { + initializeUserContext() + }) + updateInstanceTabBarHeight() const handleResize = () => updateInstanceTabBarHeight() window.addEventListener("resize", handleResize) diff --git a/packages/ui/src/components/settings/ApiStatusChecker.tsx b/packages/ui/src/components/settings/ApiStatusChecker.tsx index a53b98a..e3d84ca 100644 --- a/packages/ui/src/components/settings/ApiStatusChecker.tsx +++ b/packages/ui/src/components/settings/ApiStatusChecker.tsx @@ -1,5 +1,6 @@ import { Component, createSignal, onMount, For, Show, createEffect, on } from "solid-js" import { CheckCircle, XCircle, Loader, RefreshCw, Settings, AlertTriangle } from "lucide-solid" +import { userFetch } from "../../lib/user-context" interface ApiStatus { id: string @@ -28,7 +29,7 @@ const API_CHECKS: ApiStatusCheck[] = [ checkEnabled: async () => true, // Always available testConnection: async () => { try { - const res = await fetch("/api/opencode-zen/test") + const res = await userFetch("/api/opencode-zen/test") if (!res.ok) return false const data = await res.json() return data.connected === true @@ -43,7 +44,7 @@ const API_CHECKS: ApiStatusCheck[] = [ icon: "🦙", checkEnabled: async () => { try { - const res = await fetch("/api/ollama/config") + const res = await userFetch("/api/ollama/config") if (!res.ok) return false const data = await res.json() return data.config?.enabled === true @@ -53,7 +54,7 @@ const API_CHECKS: ApiStatusCheck[] = [ }, testConnection: async () => { try { - const res = await fetch("/api/ollama/test", { method: "POST" }) + const res = await userFetch("/api/ollama/test", { method: "POST" }) if (!res.ok) return false const data = await res.json() return data.connected === true @@ -68,7 +69,7 @@ const API_CHECKS: ApiStatusCheck[] = [ icon: "🧠", checkEnabled: async () => { try { - const res = await fetch("/api/zai/config") + const res = await userFetch("/api/zai/config") if (!res.ok) return false const data = await res.json() return data.config?.enabled === true @@ -78,7 +79,7 @@ const API_CHECKS: ApiStatusCheck[] = [ }, testConnection: async () => { try { - const res = await fetch("/api/zai/test", { method: "POST" }) + const res = await userFetch("/api/zai/test", { method: "POST" }) if (!res.ok) return false const data = await res.json() return data.connected === true diff --git a/packages/ui/src/components/settings/OllamaCloudSettings.tsx b/packages/ui/src/components/settings/OllamaCloudSettings.tsx index 8b9d7c2..427ce3b 100644 --- a/packages/ui/src/components/settings/OllamaCloudSettings.tsx +++ b/packages/ui/src/components/settings/OllamaCloudSettings.tsx @@ -4,6 +4,7 @@ import { Button } from '@suid/material' import { Cloud, CheckCircle, XCircle, Loader } from 'lucide-solid' import { instances } from '../../stores/instances' import { fetchProviders } from '../../stores/session-api' +import { userFetch } from '../../lib/user-context' interface OllamaCloudConfig { enabled: boolean @@ -34,7 +35,7 @@ const OllamaCloudSettings: Component = () => { // Load config on mount onMount(async () => { try { - const response = await fetch('/api/ollama/config') + const response = await userFetch('/api/ollama/config') if (response.ok) { const data = await response.json() const maskedKey = typeof data.config?.apiKey === "string" && /^\*+$/.test(data.config.apiKey) @@ -62,7 +63,7 @@ const OllamaCloudSettings: Component = () => { delete payload.apiKey } - const response = await fetch('/api/ollama/config', { + const response = await userFetch('/api/ollama/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) @@ -101,7 +102,7 @@ const OllamaCloudSettings: Component = () => { setConnectionStatus('testing') try { - const response = await fetch('/api/ollama/test', { + const response = await userFetch('/api/ollama/test', { method: 'POST' }) @@ -140,7 +141,7 @@ const OllamaCloudSettings: Component = () => { const loadModels = async () => { setIsLoadingModels(true) try { - const response = await fetch('/api/ollama/models') + const response = await userFetch('/api/ollama/models') if (response.ok) { const data = await response.json() // Handle different response formats diff --git a/packages/ui/src/components/settings/ZAISettings.tsx b/packages/ui/src/components/settings/ZAISettings.tsx index 00b4795..a9831c9 100644 --- a/packages/ui/src/components/settings/ZAISettings.tsx +++ b/packages/ui/src/components/settings/ZAISettings.tsx @@ -2,6 +2,7 @@ import { Component, createSignal, onMount, Show } from 'solid-js' import toast from 'solid-toast' import { Button } from '@suid/material' import { Cpu, CheckCircle, XCircle, Loader, Key, ExternalLink } from 'lucide-solid' +import { userFetch } from '../../lib/user-context' interface ZAIConfig { enabled: boolean @@ -19,7 +20,7 @@ const ZAISettings: Component = () => { // Load config on mount onMount(async () => { try { - const response = await fetch('/api/zai/config') + const response = await userFetch('/api/zai/config') if (response.ok) { const data = await response.json() setConfig(data.config) @@ -37,7 +38,7 @@ const ZAISettings: Component = () => { const saveConfig = async () => { setIsLoading(true) try { - const response = await fetch('/api/zai/config', { + const response = await userFetch('/api/zai/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config()) @@ -66,7 +67,7 @@ const ZAISettings: Component = () => { setConnectionStatus('testing') try { - const response = await fetch('/api/zai/test', { + const response = await userFetch('/api/zai/test', { method: 'POST' }) @@ -104,7 +105,7 @@ const ZAISettings: Component = () => { const loadModels = async () => { try { - const response = await fetch('/api/zai/models') + const response = await userFetch('/api/zai/models') if (response.ok) { const data = await response.json() setModels(data.models.map((m: any) => m.name)) diff --git a/packages/ui/src/lib/user-context.ts b/packages/ui/src/lib/user-context.ts new file mode 100644 index 0000000..78661d8 --- /dev/null +++ b/packages/ui/src/lib/user-context.ts @@ -0,0 +1,96 @@ +/** + * User Context utilities for frontend + * Handles active user ID and passes it in API requests + */ + +// Storage key for active user +const ACTIVE_USER_KEY = "codenomad_active_user_id" + +/** + * Set the active user ID + */ +export function setActiveUserId(userId: string | null): void { + if (userId) { + localStorage.setItem(ACTIVE_USER_KEY, userId) + console.log(`[UserContext] Active user set to: ${userId}`) + } else { + localStorage.removeItem(ACTIVE_USER_KEY) + console.log(`[UserContext] Active user cleared`) + } +} + +/** + * Get the active user ID + */ +export function getActiveUserId(): string | null { + return localStorage.getItem(ACTIVE_USER_KEY) +} + +/** + * Get headers with user ID for API requests + */ +export function getUserHeaders(): Record { + const userId = getActiveUserId() + if (userId) { + return { "X-User-Id": userId } + } + return {} +} + +/** + * Create fetch options with user headers + */ +export function withUserHeaders(options: RequestInit = {}): RequestInit { + const userHeaders = getUserHeaders() + return { + ...options, + headers: { + ...options.headers, + ...userHeaders, + }, + } +} + +/** + * Fetch wrapper that automatically includes user headers + */ +export async function userFetch(url: string, options: RequestInit = {}): Promise { + return fetch(url, withUserHeaders(options)) +} + +/** + * Initialize user context from Electron IPC + * Call this on app startup + */ +export async function initializeUserContext(): Promise { + try { + // Check if we're in Electron environment + const ipcRenderer = (window as any).electron?.ipcRenderer + if (ipcRenderer) { + const activeUser = await ipcRenderer.invoke("users:active") + if (activeUser?.id) { + setActiveUserId(activeUser.id) + console.log(`[UserContext] Initialized with user: ${activeUser.id} (${activeUser.name})`) + } else { + console.log(`[UserContext] No active user from IPC`) + } + } else { + // Web mode - try to get from localStorage or use default + const existingId = getActiveUserId() + if (existingId) { + console.log(`[UserContext] Using cached user ID: ${existingId}`) + } else { + // Set a default user ID for web mode + const defaultUserId = "default" + setActiveUserId(defaultUserId) + console.log(`[UserContext] Web mode - using default user ID`) + } + } + } catch (error) { + console.error(`[UserContext] Failed to initialize:`, error) + // Fall back to default + if (!getActiveUserId()) { + setActiveUserId("default") + } + } +}