import { resolve } from "node:path"; import { loadConfig, type AppConfig } from "../config.js"; import { parseEnvFile, writeEnvFileUpdates } from "./env-store.js"; export type RuntimeNotificationSettings = { webhookUrl: string; minSeverity: "info" | "warning" | "critical"; alwaysNotifyTypes: string[]; }; export type SecurityPolicySettings = { violationMode: "hard_abort" | "validation_fail"; allowedBinaries: string[]; commandTimeoutMs: number; inheritedEnv: string[]; scrubbedEnv: string[]; }; export type LimitSettings = { maxConcurrent: number; maxSession: number; maxRecursiveDepth: number; topologyMaxDepth: number; topologyMaxRetries: number; relationshipMaxChildren: number; mergeConflictMaxAttempts: number; portBase: number; portBlockSize: number; portBlockCount: number; portPrimaryOffset: number; }; export type UiConfigSnapshot = { envFilePath: string; runtimeEvents: RuntimeNotificationSettings; security: SecurityPolicySettings; limits: LimitSettings; paths: { stateRoot: string; projectContextPath: string; runtimeEventLogPath: string; claudeTraceLogPath: string; securityAuditLogPath: string; }; }; function toCsv(values: readonly string[]): string { return values.join(","); } function sanitizeCsv(values: readonly string[]): string[] { const output: string[] = []; const seen = new Set(); for (const value of values) { const normalized = value.trim(); if (!normalized || seen.has(normalized)) { continue; } seen.add(normalized); output.push(normalized); } return output; } function toRuntimeEvents(config: Readonly): RuntimeNotificationSettings { return { webhookUrl: config.runtimeEvents.discordWebhookUrl ?? "", minSeverity: config.runtimeEvents.discordMinSeverity, alwaysNotifyTypes: [...config.runtimeEvents.discordAlwaysNotifyTypes], }; } function toSecurity(config: Readonly): SecurityPolicySettings { return { violationMode: config.security.violationHandling, allowedBinaries: [...config.security.shellAllowedBinaries], commandTimeoutMs: config.security.commandTimeoutMs, inheritedEnv: [...config.security.inheritedEnvVars], scrubbedEnv: [...config.security.scrubbedEnvVars], }; } function toLimits(config: Readonly): LimitSettings { return { maxConcurrent: config.agentManager.maxConcurrentAgents, maxSession: config.agentManager.maxSessionAgents, maxRecursiveDepth: config.agentManager.maxRecursiveDepth, topologyMaxDepth: config.orchestration.maxDepth, topologyMaxRetries: config.orchestration.maxRetries, relationshipMaxChildren: config.orchestration.maxChildren, mergeConflictMaxAttempts: config.orchestration.mergeConflictMaxAttempts, portBase: config.provisioning.portRange.basePort, portBlockSize: config.provisioning.portRange.blockSize, portBlockCount: config.provisioning.portRange.blockCount, portPrimaryOffset: config.provisioning.portRange.primaryPortOffset, }; } function toSnapshot(config: Readonly, envFilePath: string): UiConfigSnapshot { return { envFilePath, runtimeEvents: toRuntimeEvents(config), security: toSecurity(config), limits: toLimits(config), paths: { stateRoot: config.orchestration.stateRoot, projectContextPath: config.orchestration.projectContextPath, runtimeEventLogPath: config.runtimeEvents.logPath, claudeTraceLogPath: config.provider.claudeObservability.logPath, securityAuditLogPath: config.security.auditLogPath, }, }; } function mergeEnv(fileValues: Record): NodeJS.ProcessEnv { return { ...process.env, ...fileValues, }; } export class UiConfigStore { private readonly envFilePath: string; constructor(input: { workspaceRoot: string; envFilePath?: string }) { this.envFilePath = resolve(input.workspaceRoot, input.envFilePath ?? ".env"); } getEnvFilePath(): string { return this.envFilePath; } async readSnapshot(): Promise { const parsed = await parseEnvFile(this.envFilePath); const config = loadConfig(mergeEnv(parsed.values)); return toSnapshot(config, this.envFilePath); } async updateRuntimeEvents(input: RuntimeNotificationSettings): Promise { const alwaysNotifyTypes = sanitizeCsv(input.alwaysNotifyTypes); const updates: Record = { AGENT_RUNTIME_DISCORD_WEBHOOK_URL: input.webhookUrl.trim(), AGENT_RUNTIME_DISCORD_MIN_SEVERITY: input.minSeverity, AGENT_RUNTIME_DISCORD_ALWAYS_NOTIFY_TYPES: toCsv(alwaysNotifyTypes), }; const parsed = await writeEnvFileUpdates(this.envFilePath, updates); const config = loadConfig(mergeEnv(parsed.values)); return toSnapshot(config, this.envFilePath); } async updateSecurityPolicy(input: SecurityPolicySettings): Promise { const updates: Record = { AGENT_SECURITY_VIOLATION_MODE: input.violationMode, AGENT_SECURITY_ALLOWED_BINARIES: toCsv(sanitizeCsv(input.allowedBinaries)), AGENT_SECURITY_COMMAND_TIMEOUT_MS: String(input.commandTimeoutMs), AGENT_SECURITY_ENV_INHERIT: toCsv(sanitizeCsv(input.inheritedEnv)), AGENT_SECURITY_ENV_SCRUB: toCsv(sanitizeCsv(input.scrubbedEnv)), }; const parsed = await writeEnvFileUpdates(this.envFilePath, updates); const config = loadConfig(mergeEnv(parsed.values)); return toSnapshot(config, this.envFilePath); } async updateLimits(input: LimitSettings): Promise { const updates: Record = { AGENT_MAX_CONCURRENT: String(input.maxConcurrent), AGENT_MAX_SESSION: String(input.maxSession), AGENT_MAX_RECURSIVE_DEPTH: String(input.maxRecursiveDepth), AGENT_TOPOLOGY_MAX_DEPTH: String(input.topologyMaxDepth), AGENT_TOPOLOGY_MAX_RETRIES: String(input.topologyMaxRetries), AGENT_RELATIONSHIP_MAX_CHILDREN: String(input.relationshipMaxChildren), AGENT_MERGE_CONFLICT_MAX_ATTEMPTS: String(input.mergeConflictMaxAttempts), AGENT_PORT_BASE: String(input.portBase), AGENT_PORT_BLOCK_SIZE: String(input.portBlockSize), AGENT_PORT_BLOCK_COUNT: String(input.portBlockCount), AGENT_PORT_PRIMARY_OFFSET: String(input.portPrimaryOffset), }; const parsed = await writeEnvFileUpdates(this.envFilePath, updates); const config = loadConfig(mergeEnv(parsed.values)); return toSnapshot(config, this.envFilePath); } }