UNPKG

consortium

Version:

Remote control and session sharing CLI for AI coding agents

374 lines (371 loc) 13 kB
import { readFile, open, stat, unlink, mkdir, writeFile, rename } from 'node:fs/promises'; import { existsSync, constants, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { c as configuration, l as logger, e as encodeBase64 } from './types-WAGIe5yd.mjs'; import * as z from 'zod'; import 'axios'; import 'chalk'; import 'fs'; import 'node:os'; import 'node:path'; import 'node:events'; import 'socket.io-client'; import 'node:crypto'; import 'tweetnacl'; import 'child_process'; import 'util'; import 'fs/promises'; import 'crypto'; import 'path'; import 'url'; import 'os'; import 'expo-server-sdk'; const AnthropicConfigSchema = z.object({ baseUrl: z.string().url().optional(), authToken: z.string().optional(), model: z.string().optional() }); const OpenAIConfigSchema = z.object({ apiKey: z.string().optional(), baseUrl: z.string().url().optional(), model: z.string().optional() }); const AzureOpenAIConfigSchema = z.object({ apiKey: z.string().optional(), endpoint: z.string().url().optional(), apiVersion: z.string().optional(), deploymentName: z.string().optional() }); const TogetherAIConfigSchema = z.object({ apiKey: z.string().optional(), model: z.string().optional() }); const TmuxConfigSchema = z.object({ sessionName: z.string().optional(), tmpDir: z.string().optional(), updateEnvironment: z.boolean().optional() }); const EnvironmentVariableSchema = z.object({ name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, "Invalid environment variable name"), value: z.string() }); const ProfileCompatibilitySchema = z.object({ claude: z.boolean().default(true), codex: z.boolean().default(true), gemini: z.boolean().default(true) }); const AIBackendProfileSchema = z.object({ id: z.string().uuid(), name: z.string().min(1).max(100), description: z.string().max(500).optional(), // Agent-specific configurations anthropicConfig: AnthropicConfigSchema.optional(), openaiConfig: OpenAIConfigSchema.optional(), azureOpenAIConfig: AzureOpenAIConfigSchema.optional(), togetherAIConfig: TogetherAIConfigSchema.optional(), // Tmux configuration tmuxConfig: TmuxConfigSchema.optional(), // Environment variables (validated) environmentVariables: z.array(EnvironmentVariableSchema).default([]), // Default session type for this profile defaultSessionType: z.enum(["simple", "worktree"]).optional(), // Default permission mode for this profile (supports both Claude and Codex modes) defaultPermissionMode: z.enum([ "default", "acceptEdits", "bypassPermissions", "plan", // Claude modes "read-only", "safe-yolo", "yolo" // Codex modes ]).optional(), // Default model mode for this profile defaultModelMode: z.string().optional(), // Compatibility metadata compatibility: ProfileCompatibilitySchema.default({ claude: true, codex: true, gemini: true }), // Built-in profile indicator isBuiltIn: z.boolean().default(false), // Metadata createdAt: z.number().default(() => Date.now()), updatedAt: z.number().default(() => Date.now()), version: z.string().default("1.0.0") }); function validateProfileForAgent(profile, agent) { return profile.compatibility[agent]; } function getProfileEnvironmentVariables(profile) { const envVars = {}; profile.environmentVariables.forEach((envVar) => { envVars[envVar.name] = envVar.value; }); if (profile.anthropicConfig) { if (profile.anthropicConfig.baseUrl) envVars.ANTHROPIC_BASE_URL = profile.anthropicConfig.baseUrl; if (profile.anthropicConfig.authToken) envVars.ANTHROPIC_AUTH_TOKEN = profile.anthropicConfig.authToken; if (profile.anthropicConfig.model) envVars.ANTHROPIC_MODEL = profile.anthropicConfig.model; } if (profile.openaiConfig) { if (profile.openaiConfig.apiKey) envVars.OPENAI_API_KEY = profile.openaiConfig.apiKey; if (profile.openaiConfig.baseUrl) envVars.OPENAI_BASE_URL = profile.openaiConfig.baseUrl; if (profile.openaiConfig.model) envVars.OPENAI_MODEL = profile.openaiConfig.model; } if (profile.azureOpenAIConfig) { if (profile.azureOpenAIConfig.apiKey) envVars.AZURE_OPENAI_API_KEY = profile.azureOpenAIConfig.apiKey; if (profile.azureOpenAIConfig.endpoint) envVars.AZURE_OPENAI_ENDPOINT = profile.azureOpenAIConfig.endpoint; if (profile.azureOpenAIConfig.apiVersion) envVars.AZURE_OPENAI_API_VERSION = profile.azureOpenAIConfig.apiVersion; if (profile.azureOpenAIConfig.deploymentName) envVars.AZURE_OPENAI_DEPLOYMENT_NAME = profile.azureOpenAIConfig.deploymentName; } if (profile.togetherAIConfig) { if (profile.togetherAIConfig.apiKey) envVars.TOGETHER_API_KEY = profile.togetherAIConfig.apiKey; if (profile.togetherAIConfig.model) envVars.TOGETHER_MODEL = profile.togetherAIConfig.model; } if (profile.tmuxConfig) { if (profile.tmuxConfig.sessionName !== void 0) envVars.TMUX_SESSION_NAME = profile.tmuxConfig.sessionName; if (profile.tmuxConfig.tmpDir) envVars.TMUX_TMPDIR = profile.tmuxConfig.tmpDir; if (profile.tmuxConfig.updateEnvironment !== void 0) { envVars.TMUX_UPDATE_ENVIRONMENT = profile.tmuxConfig.updateEnvironment.toString(); } } return envVars; } const SUPPORTED_SCHEMA_VERSION = 2; const defaultSettings = { schemaVersion: SUPPORTED_SCHEMA_VERSION, onboardingCompleted: false, profiles: [], localEnvironmentVariables: {} }; function migrateSettings(raw, fromVersion) { let migrated = { ...raw }; if (fromVersion < 2) { if (!migrated.profiles) { migrated.profiles = []; } if (!migrated.localEnvironmentVariables) { migrated.localEnvironmentVariables = {}; } migrated.schemaVersion = 2; } return migrated; } async function readSettings() { if (!existsSync(configuration.settingsFile)) { return { ...defaultSettings }; } try { const content = await readFile(configuration.settingsFile, "utf8"); const raw = JSON.parse(content); const schemaVersion = raw.schemaVersion ?? 1; if (schemaVersion > SUPPORTED_SCHEMA_VERSION) { logger.warn( `\u26A0\uFE0F Settings schema v${schemaVersion} > supported v${SUPPORTED_SCHEMA_VERSION}. Update consortium-cli for full functionality.` ); } const migrated = migrateSettings(raw, schemaVersion); if (migrated.profiles && Array.isArray(migrated.profiles)) { const validProfiles = []; for (const profile of migrated.profiles) { try { const validated = AIBackendProfileSchema.parse(profile); validProfiles.push(validated); } catch (error) { logger.warn( `\u26A0\uFE0F Invalid profile "${profile?.name || profile?.id || "unknown"}" - skipping. Error: ${error.message}` ); } } migrated.profiles = validProfiles; } return { ...defaultSettings, ...migrated }; } catch (error) { logger.warn(`Failed to read settings: ${error.message}`); return { ...defaultSettings }; } } async function updateSettings(updater) { const LOCK_RETRY_INTERVAL_MS = 100; const MAX_LOCK_ATTEMPTS = 50; const STALE_LOCK_TIMEOUT_MS = 1e4; const lockFile = configuration.settingsFile + ".lock"; const tmpFile = configuration.settingsFile + ".tmp"; let fileHandle; let attempts = 0; while (attempts < MAX_LOCK_ATTEMPTS) { try { fileHandle = await open(lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY); break; } catch (err) { if (err.code === "EEXIST") { attempts++; await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)); try { const stats = await stat(lockFile); if (Date.now() - stats.mtimeMs > STALE_LOCK_TIMEOUT_MS) { await unlink(lockFile).catch(() => { }); } } catch { } } else { throw err; } } } if (!fileHandle) { throw new Error(`Failed to acquire settings lock after ${MAX_LOCK_ATTEMPTS * LOCK_RETRY_INTERVAL_MS / 1e3} seconds`); } try { const current = await readSettings() || { ...defaultSettings }; const updated = await updater(current); if (!existsSync(configuration.consortiumHomeDir)) { await mkdir(configuration.consortiumHomeDir, { recursive: true }); } await writeFile(tmpFile, JSON.stringify(updated, null, 2)); await rename(tmpFile, configuration.settingsFile); return updated; } finally { await fileHandle.close(); await unlink(lockFile).catch(() => { }); } } const credentialsSchema = z.object({ token: z.string(), secret: z.string().base64().nullish(), // Legacy encryption: z.object({ publicKey: z.string().base64(), machineKey: z.string().base64() }).nullish() }); async function readCredentials() { if (!existsSync(configuration.privateKeyFile)) { return null; } try { const keyBase64 = await readFile(configuration.privateKeyFile, "utf8"); const credentials = credentialsSchema.parse(JSON.parse(keyBase64)); if (credentials.secret) { return { token: credentials.token, encryption: { type: "legacy", secret: new Uint8Array(Buffer.from(credentials.secret, "base64")) } }; } else if (credentials.encryption) { return { token: credentials.token, encryption: { type: "dataKey", publicKey: new Uint8Array(Buffer.from(credentials.encryption.publicKey, "base64")), machineKey: new Uint8Array(Buffer.from(credentials.encryption.machineKey, "base64")) } }; } } catch { return null; } return null; } async function writeCredentialsLegacy(credentials) { if (!existsSync(configuration.consortiumHomeDir)) { await mkdir(configuration.consortiumHomeDir, { recursive: true }); } await writeFile(configuration.privateKeyFile, JSON.stringify({ secret: encodeBase64(credentials.secret), token: credentials.token }, null, 2)); } async function writeCredentialsDataKey(credentials) { if (!existsSync(configuration.consortiumHomeDir)) { await mkdir(configuration.consortiumHomeDir, { recursive: true }); } await writeFile(configuration.privateKeyFile, JSON.stringify({ encryption: { publicKey: encodeBase64(credentials.publicKey), machineKey: encodeBase64(credentials.machineKey) }, token: credentials.token }, null, 2)); } async function clearCredentials() { if (existsSync(configuration.privateKeyFile)) { await unlink(configuration.privateKeyFile); } } async function clearMachineId() { await updateSettings((settings) => ({ ...settings, machineId: void 0 })); } async function readDaemonState() { try { if (!existsSync(configuration.daemonStateFile)) { return null; } const content = await readFile(configuration.daemonStateFile, "utf-8"); return JSON.parse(content); } catch (error) { console.error(`[PERSISTENCE] Daemon state file corrupted: ${configuration.daemonStateFile}`, error); return null; } } function writeDaemonState(state) { writeFileSync(configuration.daemonStateFile, JSON.stringify(state, null, 2), "utf-8"); } async function clearDaemonState() { if (existsSync(configuration.daemonStateFile)) { await unlink(configuration.daemonStateFile); } if (existsSync(configuration.daemonLockFile)) { try { await unlink(configuration.daemonLockFile); } catch { } } } async function acquireDaemonLock(maxAttempts = 5, delayIncrementMs = 200) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const fileHandle = await open( configuration.daemonLockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY ); await fileHandle.writeFile(String(process.pid)); return fileHandle; } catch (error) { if (error.code === "EEXIST") { try { const lockPid = readFileSync(configuration.daemonLockFile, "utf-8").trim(); if (lockPid && !isNaN(Number(lockPid))) { try { process.kill(Number(lockPid), 0); } catch { unlinkSync(configuration.daemonLockFile); continue; } } } catch { } } if (attempt === maxAttempts) { return null; } const delayMs = attempt * delayIncrementMs; await new Promise((resolve) => setTimeout(resolve, delayMs)); } } return null; } async function releaseDaemonLock(lockHandle) { try { await lockHandle.close(); } catch { } try { if (existsSync(configuration.daemonLockFile)) { unlinkSync(configuration.daemonLockFile); } } catch { } } export { AIBackendProfileSchema, SUPPORTED_SCHEMA_VERSION, acquireDaemonLock, clearCredentials, clearDaemonState, clearMachineId, getProfileEnvironmentVariables, readCredentials, readDaemonState, readSettings, releaseDaemonLock, updateSettings, validateProfileForAgent, writeCredentialsDataKey, writeCredentialsLegacy, writeDaemonState };