feat: complete per-user integration config isolation and UI initialization
Some checks failed
Release Binaries / release (push) Has been cancelled

This commit is contained in:
Gemini AI
2025-12-29 01:13:31 +04:00
Unverified
parent 721da6f2ee
commit 8474be8559
9 changed files with 284 additions and 69 deletions

View File

@@ -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<OllamaCloudConfig>): void {
function updateOllamaConfig(config: Partial<OllamaCloudConfig>, 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<OllamaCloudConfig>): 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)
}

View File

@@ -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<ZAIConfig>
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<ZAIConfig>): void {
function updateZAIConfig(config: Partial<ZAIConfig>, 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)

View File

@@ -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, string | string[] | undefined> }): 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
}

View File

@@ -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 {