diff --git a/src/commands/validate-options.ts b/src/commands/validate-options.ts index d36e137f5..64d04206c 100644 --- a/src/commands/validate-options.ts +++ b/src/commands/validate-options.ts @@ -1,42 +1,22 @@ -import * as path from 'path'; -import * as fs from 'fs'; -import { WrapperConfig, LogLevel } from '../types'; -import { logger } from '../logger'; -import { SQUID_DANGEROUS_CHARS } from '../domain-patterns'; -import { parseDomains, processAgentImageOption } from '../domain-utils'; -import { - validateApiProxyConfig, - validateAnthropicCacheTailTtl, - emitApiProxyTargetWarnings, - emitCliProxyStatusLogs, - warnClassicPATWithCopilotModel, -} from '../api-proxy-config'; -import { - buildRateLimitConfig, - validateRateLimitFlags, - validateEnableOpenCodeFlag, - validateEnableTokenSteeringFlag, - validateSkipPullWithBuildLocal, - validateAllowHostPorts, - applyHostServicePortsConfig, - parseMemoryLimit, - applyAgentTimeout, - checkDockerHost, - resolveDockerHostPathPrefix, - parseEnvironmentVariables, - parseVolumeMounts, - parseModelMultipliersCli, -} from '../option-parsers'; -import { resolveAllowedDomains, resolveBlockedDomains } from './preflight'; -import { resolveNetworkConfig } from './network-setup'; -import { buildConfig } from './build-config'; +import { WrapperConfig } from '../types'; +import { validateLogAndLimits } from './validators/log-and-limits'; +import { validateNetworkOptions } from './validators/network-options'; +import { validateAgentOptions } from './validators/agent-options'; +import { assembleAndValidateConfig } from './validators/config-assembly'; /** * Validates all CLI options and assembles the {@link WrapperConfig}. * - * All pre-flight validation guards live here. The function calls - * `process.exit(1)` on any validation failure so the caller always receives - * a fully-validated, ready-to-use config object. + * Delegates each concern to a focused sub-validator and then assembles the + * final config. The function calls `process.exit(1)` (via the sub-validators) + * on any validation failure so the caller always receives a fully-validated, + * ready-to-use config object. + * + * Sub-validators: + * - {@link validateLogAndLimits} — log level, model multipliers, resource limits + * - {@link validateNetworkOptions} — Docker host, domain resolution, network config + * - {@link validateAgentOptions} — env vars, volume mounts, SSL Bump URL patterns + * - {@link assembleAndValidateConfig} — config assembly + post-config assertions * * @param options Raw Commander options object (already mutated by * {@link applyConfigFilePrecedence} when a --config file is present). @@ -46,452 +26,8 @@ export function validateOptions( options: Record, agentCommand: string, ): WrapperConfig { - // --- Log level ----------------------------------------------------------- - - const logLevel = options.logLevel as LogLevel; - if (!['debug', 'info', 'warn', 'error'].includes(logLevel)) { - console.error(`Invalid log level: ${logLevel}`); - process.exit(1); - } - - // Validate --anthropic-cache-tail-ttl if provided - validateAnthropicCacheTailTtl(options.anthropicCacheTailTtl as string | undefined); - - // --- Model multipliers --------------------------------------------------- - - // Model aliases may be injected via config file (not a Commander option), - // so access through a Record cast with a proper type annotation. - const modelAliases = (options as Record).modelAliases as - | Record - | undefined; - const maxEffectiveTokensOption = (options as Record).maxEffectiveTokens as - | string - | number - | undefined; - // Config-file multipliers (already a Record) - const configFileMultipliers = (options as Record) - .effectiveTokenModelMultipliers as Record | undefined; - // CLI multipliers via --max-model-multiplier (model:multiplier,... format) - const maxModelMultiplierRaw = (options as Record).maxModelMultiplier as - | string - | undefined; - let cliMultipliers: Record | undefined; - if (maxModelMultiplierRaw !== undefined) { - const parsed = parseModelMultipliersCli(maxModelMultiplierRaw); - if ('error' in parsed) { - console.error(`Error: ${parsed.error}`); - process.exit(1); - } - cliMultipliers = parsed.multipliers; - } - // CLI flag overrides config-file values for the same model name. - const effectiveTokenModelMultipliers = - configFileMultipliers || cliMultipliers - ? { ...configFileMultipliers, ...cliMultipliers } - : undefined; - const maxEffectiveTokens = - maxEffectiveTokensOption !== undefined ? Number(maxEffectiveTokensOption) : undefined; - - if ( - maxEffectiveTokens !== undefined && - (!Number.isInteger(maxEffectiveTokens) || maxEffectiveTokens <= 0) - ) { - console.error('Error: Invalid maxEffectiveTokens value (must be a positive integer)'); - process.exit(1); - } - - const maxRunsOption = (options as Record).maxRuns as - | string - | number - | undefined; - const maxRuns = maxRunsOption !== undefined ? Number(maxRunsOption) : undefined; - - if (maxRuns !== undefined && (!Number.isInteger(maxRuns) || maxRuns <= 0)) { - console.error('Error: Invalid maxRuns value (must be a positive integer)'); - process.exit(1); - } - - logger.setLevel(logLevel); - - // --- Docker host --------------------------------------------------------- - - // When DOCKER_HOST points at an external TCP daemon (e.g. workflow-scope DinD), - // AWF redirects its own docker calls to the local socket automatically. - // The original DOCKER_HOST value is forwarded into the agent container so the - // agent workload can still reach the DinD daemon. - const dockerHostCheck = checkDockerHost(); - if (!dockerHostCheck.valid) { - logger.warn( - '⚠️ External DOCKER_HOST detected. AWF will redirect its own Docker calls to the local socket.', - ); - logger.warn( - ' The original DOCKER_HOST (and related Docker client env vars) are forwarded into the agent container.', - ); - } - const dockerHostPathPrefixResolution = resolveDockerHostPathPrefix( - dockerHostCheck, - options.dockerHostPathPrefix as string | undefined, - ); - if (!dockerHostCheck.valid && !dockerHostPathPrefixResolution.dockerHostPathPrefix) { - logger.warn( - '⚠️ If your Docker daemon uses a split runner/daemon filesystem, set --docker-host-path-prefix (for example: /host).', - ); - } - - // --- Domain resolution -------------------------------------------------- - - // Resolve allowed and blocked domains (parse, merge, validate) - const { - allowedDomains, - localhostResult, - resolvedCopilotApiTarget, - resolvedCopilotApiBasePath, - } = resolveAllowedDomains(options); - - const blockedDomains = resolveBlockedDomains(options); - - // --- Environment variables ----------------------------------------------- - - // Parse additional environment variables from --env flags - let additionalEnv: Record = {}; - if (options.env && Array.isArray(options.env)) { - const parsed = parseEnvironmentVariables(options.env as string[]); - if (!parsed.success) { - logger.error( - `Invalid environment variable format: ${parsed.invalidVar} (expected KEY=VALUE)`, - ); - process.exit(1); - } - additionalEnv = parsed.env; - } - - // Validate --env-file path if provided - if (options.envFile) { - if (!fs.existsSync(options.envFile as string)) { - logger.error(`--env-file: file not found: ${options.envFile}`); - process.exit(1); - } - } - - // --- Volume mounts ------------------------------------------------------- - - // Parse and validate volume mounts from --mount flags - let volumeMounts: string[] | undefined; - if (options.mount && Array.isArray(options.mount) && (options.mount as string[]).length > 0) { - const parsed = parseVolumeMounts(options.mount as string[]); - if (!parsed.success) { - logger.error(`Invalid volume mount: ${parsed.invalidMount}`); - logger.error(`Reason: ${parsed.reason}`); - process.exit(1); - } - volumeMounts = parsed.mounts; - logger.debug(`Parsed ${volumeMounts.length} volume mount(s)`); - } - - // --- Network configuration ----------------------------------------------- - - // Resolve network configuration (upstream proxy, DNS servers, DNS-over-HTTPS) - const { upstreamProxy, dnsServers, dnsOverHttps } = resolveNetworkConfig(options); - - // --- SSL Bump URL patterns ----------------------------------------------- - - // Parse --allow-urls for SSL Bump mode - let allowedUrls: string[] | undefined; - if (options.allowUrls) { - allowedUrls = parseDomains(options.allowUrls as string); - if (allowedUrls.length > 0 && !options.sslBump) { - logger.error('--allow-urls requires --ssl-bump to be enabled'); - process.exit(1); - } - - // Validate URL patterns for security - for (const url of allowedUrls) { - // URL patterns must start with https:// - if (!url.startsWith('https://')) { - logger.error(`URL patterns must start with https:// (got: ${url})`); - logger.error('Use --allow-domains for domain-level filtering without SSL Bump'); - process.exit(1); - } - - // Reject overly broad patterns that would bypass security - const dangerousPatterns = [ - /^https:\/\/\*$/, // https://* - /^https:\/\/\*\.\*$/, // https://*.* - /^https:\/\/\.\*$/, // https://.* - /^\.\*$/, // .* - /^\*$/, // * - /^https:\/\/[^/]*\*[^/]*$/, // https://*anything* without path - ]; - - for (const pattern of dangerousPatterns) { - if (pattern.test(url)) { - logger.error(`URL pattern "${url}" is too broad and would bypass security controls`); - logger.error( - 'URL patterns must include a specific domain and path, e.g., https://github.com/org/*', - ); - process.exit(1); - } - } - - // Reject characters that could inject Squid config directives or tokens - if (SQUID_DANGEROUS_CHARS.test(url)) { - logger.error( - `URL pattern contains characters unsafe for Squid config: ${JSON.stringify(url)}`, - ); - logger.error( - 'URL patterns must not contain whitespace, quotes, semicolons, backticks, hash characters, or null bytes.', - ); - process.exit(1); - } - - // Ensure pattern has a path component (not just domain) - const urlWithoutScheme = url.replace(/^https:\/\//, ''); - if (!urlWithoutScheme.includes('/')) { - logger.error(`URL pattern "${url}" must include a path component`); - logger.error('For domain-only filtering, use --allow-domains instead'); - logger.error('Example: https://github.com/myorg/* (includes path)'); - process.exit(1); - } - } - } - - // Validate SSL Bump option - if (options.sslBump) { - logger.info('SSL Bump mode enabled - HTTPS content inspection will be performed'); - logger.warn('⚠️ SSL Bump intercepts HTTPS traffic. Only use for trusted workloads.'); - } - - // Log DLP mode - if (options.enableDlp) { - logger.info('DLP scanning enabled - outbound requests will be scanned for credential patterns'); - } - - // --- Resource limits ----------------------------------------------------- - - // Validate memory limit - const memoryLimit = parseMemoryLimit(options.memoryLimit as string); - if (memoryLimit.error) { - logger.error(memoryLimit.error); - process.exit(1); - } - - // Validate agent image option - const agentImageResult = processAgentImageOption( - options.agentImage as string | undefined, - options.buildLocal as boolean, - ); - if (agentImageResult.error) { - logger.error(agentImageResult.error); - process.exit(1); - } - if (agentImageResult.infoMessage) { - logger.info(agentImageResult.infoMessage); - } - const agentImage = agentImageResult.agentImage; - - // --- Config assembly ----------------------------------------------------- - - const config = buildConfig({ - options, - agentCommand, - logLevel, - allowedDomains, - blockedDomains, - localhostDetected: localhostResult.localhostDetected, - additionalEnv, - volumeMounts, - upstreamProxy, - dnsServers, - dnsOverHttps, - allowedUrls, - memoryLimit: memoryLimit.value, - agentImage, - modelAliases, - maxEffectiveTokens, - effectiveTokenModelMultipliers, - maxRuns, - resolvedCopilotApiTarget, - resolvedCopilotApiBasePath, - dockerHostPathPrefix: dockerHostPathPrefixResolution.dockerHostPathPrefix, - }); - - // --- Post-config validations --------------------------------------------- - - // Apply --docker-host override for AWF's own container operations. - // This must be called before startContainers/stopContainers/runAgentCommand. - if (config.awfDockerHost && !config.awfDockerHost.startsWith('unix://')) { - logger.error(`❌ --docker-host must be a unix:// socket URI, got: ${config.awfDockerHost}`); - logger.error(' Example: --docker-host unix:///run/user/1000/docker.sock'); - process.exit(1); - } - if (config.dockerHostPathPrefix && !config.dockerHostPathPrefix.startsWith('/')) { - logger.error( - `❌ --docker-host-path-prefix must be an absolute path, got: ${config.dockerHostPathPrefix}`, - ); - logger.error(' Example: --docker-host-path-prefix /host'); - process.exit(1); - } - - // Parse and validate --agent-timeout - applyAgentTimeout(options.agentTimeout as string | undefined, config, logger); - - // Build rate limit config when API proxy is enabled - if (config.enableApiProxy) { - const rateLimitResult = buildRateLimitConfig(options); - if ('error' in rateLimitResult) { - logger.error(`❌ ${rateLimitResult.error}`); - process.exit(1); - } - config.rateLimitConfig = rateLimitResult.config; - logger.debug( - `Rate limiting: enabled=${rateLimitResult.config.enabled}, rpm=${rateLimitResult.config.rpm}, rph=${rateLimitResult.config.rph}, bytesPm=${rateLimitResult.config.bytesPm}`, - ); - } - - // Error if rate limit flags are used without --enable-api-proxy - const rateLimitFlagValidation = validateRateLimitFlags(config.enableApiProxy ?? false, options); - if (!rateLimitFlagValidation.valid) { - logger.error(rateLimitFlagValidation.error!); - process.exit(1); - } - - // Error if --enable-opencode is used without --enable-api-proxy - const enableOpenCodeValidation = validateEnableOpenCodeFlag( - config.enableApiProxy ?? false, - config.enableOpenCode ?? false, - ); - if (!enableOpenCodeValidation.valid) { - logger.error(enableOpenCodeValidation.error!); - process.exit(1); - } - - // Error if --enable-token-steering is used without --enable-api-proxy - const enableTokenSteeringValidation = validateEnableTokenSteeringFlag( - config.enableApiProxy ?? false, - config.enableTokenSteering ?? false, - ); - if (!enableTokenSteeringValidation.valid) { - logger.error(enableTokenSteeringValidation.error!); - process.exit(1); - } - - // Warn if --env-all is used - if (config.envAll) { - logger.warn('⚠️ Using --env-all: All host environment variables will be passed to container'); - logger.warn(' This may expose sensitive credentials if logs or configs are shared'); - } - - // Log --env-file usage - if (config.envFile) { - logger.debug(`Loading environment variables from file: ${config.envFile}`); - } - - // Validate --allow-host-service-ports (port format & range) - const servicePortsResult = applyHostServicePortsConfig( - config.allowHostServicePorts, - config.enableHostAccess, - logger, - ); - if (!servicePortsResult.valid) { - logger.error(`❌ ${servicePortsResult.error}`); - process.exit(1); - } - config.enableHostAccess = servicePortsResult.enableHostAccess; - - // Validate --allow-host-ports requires --enable-host-access - const hostPortsValidation = validateAllowHostPorts( - config.allowHostPorts, - config.enableHostAccess, - ); - if (!hostPortsValidation.valid) { - logger.error(`❌ ${hostPortsValidation.error}`); - process.exit(1); - } - - // Error if --skip-pull is used with --build-local (incompatible flags) - const skipPullValidation = validateSkipPullWithBuildLocal(config.skipPull, config.buildLocal); - if (!skipPullValidation.valid) { - logger.error(`❌ ${skipPullValidation.error}`); - process.exit(1); - } - - // Warn if --enable-host-access is used with host.docker.internal in allowed domains - if (config.enableHostAccess) { - const hasHostDomain = allowedDomains.some( - (d) => d === 'host.docker.internal' || d.endsWith('.host.docker.internal'), - ); - if (hasHostDomain) { - logger.warn('⚠️ Host access enabled with host.docker.internal in allowed domains'); - logger.warn(' Containers can access ANY service running on the host machine'); - logger.warn(' Only use this for trusted workloads (e.g., MCP gateways)'); - } - } - - // Validate and warn about API proxy configuration - // Pass booleans (not actual keys) to prevent sensitive data flow to logger - const apiProxyValidation = validateApiProxyConfig( - config.enableApiProxy || false, - !!config.openaiApiKey, - !!config.anthropicApiKey, - !!(config.copilotGithubToken || config.copilotApiKey), - !!config.geminiApiKey, - ); - - // Log API proxy status at info level for visibility - if (config.enableApiProxy) { - logger.info( - `API proxy enabled: OpenAI=${!!config.openaiApiKey}, Anthropic=${!!config.anthropicApiKey}, Copilot=${!!(config.copilotGithubToken || config.copilotApiKey)}, Gemini=${!!config.geminiApiKey}`, - ); - } - - for (const warning of apiProxyValidation.warnings) { - logger.warn(warning); - } - for (const msg of apiProxyValidation.debugMessages) { - logger.debug(msg); - } - - // Warn if custom API targets are not in --allow-domains - emitApiProxyTargetWarnings(config, allowedDomains, logger.warn.bind(logger)); - - // Log CLI proxy status - emitCliProxyStatusLogs(config, logger.info.bind(logger), logger.warn.bind(logger)); - - // Warn if a classic PAT is combined with COPILOT_MODEL (Copilot CLI 1.0.21+ incompatibility) - const hasCopilotModelInEnvFiles = (envFile: unknown): boolean => { - const envFiles = Array.isArray(envFile) ? envFile : envFile ? [envFile] : []; - for (const candidate of envFiles) { - if (typeof candidate !== 'string' || candidate.trim() === '') continue; - try { - const envFilePath = path.isAbsolute(candidate) - ? candidate - : path.resolve(process.cwd(), candidate); - const envFileContents = fs.readFileSync(envFilePath, 'utf8'); - for (const line of envFileContents.split(/\r?\n/)) { - const trimmedLine = line.trim(); - if (!trimmedLine || trimmedLine.startsWith('#')) continue; - if (/^(?:export\s+)?COPILOT_MODEL\s*=/.test(trimmedLine)) { - return true; - } - } - } catch { - // Ignore unreadable env files here; this check is only for a pre-flight warning. - } - } - return false; - }; - - // Check if COPILOT_MODEL is set via --env/-e flags, host env (when --env-all is active), or --env-file - const copilotModelFromFlags = !!additionalEnv['COPILOT_MODEL']; - const copilotModelInHostEnv = !!(config.envAll && process.env.COPILOT_MODEL); - const copilotModelInEnvFile = hasCopilotModelInEnvFiles( - (config as { envFile?: unknown }).envFile, - ); - warnClassicPATWithCopilotModel( - config.copilotGithubToken?.startsWith('ghp_') ?? false, - copilotModelFromFlags || copilotModelInHostEnv || copilotModelInEnvFile, - logger.warn.bind(logger), - ); - - return config; + const logAndLimits = validateLogAndLimits(options); + const networkOptions = validateNetworkOptions(options); + const agentOptions = validateAgentOptions(options); + return assembleAndValidateConfig(options, agentCommand, logAndLimits, networkOptions, agentOptions); } diff --git a/src/commands/validators/agent-options.ts b/src/commands/validators/agent-options.ts new file mode 100644 index 000000000..680b8bcd9 --- /dev/null +++ b/src/commands/validators/agent-options.ts @@ -0,0 +1,149 @@ +import * as fs from 'fs'; +import { logger } from '../../logger'; +import { SQUID_DANGEROUS_CHARS } from '../../domain-patterns'; +import { parseDomains } from '../../domain-utils'; +import { + parseEnvironmentVariables, + parseVolumeMounts, +} from '../../option-parsers'; + +/** + * The result produced by {@link validateAgentOptions}. + */ +export interface AgentOptionsResult { + additionalEnv: Record; + volumeMounts: string[] | undefined; + allowedUrls: string[] | undefined; +} + +/** + * Validates agent-runtime options: environment variables, volume mounts, and + * SSL Bump URL patterns. + * + * Covers the following option groups: + * - `--env` / `--env-file` + * - `--mount` + * - `--allow-urls`, `--ssl-bump` + * - `--enable-dlp` + * + * Calls `process.exit(1)` on any validation failure so the caller always + * receives a fully-validated result. + */ +export function validateAgentOptions(options: Record): AgentOptionsResult { + // --- Environment variables ----------------------------------------------- + + // Parse additional environment variables from --env flags + let additionalEnv: Record = {}; + if (options.env && Array.isArray(options.env)) { + const parsed = parseEnvironmentVariables(options.env as string[]); + if (!parsed.success) { + logger.error( + `Invalid environment variable format: ${parsed.invalidVar} (expected KEY=VALUE)`, + ); + process.exit(1); + } + additionalEnv = parsed.env; + } + + // Validate --env-file path if provided + if (options.envFile) { + if (!fs.existsSync(options.envFile as string)) { + logger.error(`--env-file: file not found: ${options.envFile}`); + process.exit(1); + } + } + + // --- Volume mounts ------------------------------------------------------- + + // Parse and validate volume mounts from --mount flags + let volumeMounts: string[] | undefined; + if (options.mount && Array.isArray(options.mount) && (options.mount as string[]).length > 0) { + const parsed = parseVolumeMounts(options.mount as string[]); + if (!parsed.success) { + logger.error(`Invalid volume mount: ${parsed.invalidMount}`); + logger.error(`Reason: ${parsed.reason}`); + process.exit(1); + } + volumeMounts = parsed.mounts; + logger.debug(`Parsed ${volumeMounts.length} volume mount(s)`); + } + + // --- SSL Bump URL patterns ----------------------------------------------- + + // Parse --allow-urls for SSL Bump mode + let allowedUrls: string[] | undefined; + if (options.allowUrls) { + allowedUrls = parseDomains(options.allowUrls as string); + if (allowedUrls.length > 0 && !options.sslBump) { + logger.error('--allow-urls requires --ssl-bump to be enabled'); + process.exit(1); + } + + // Validate URL patterns for security + for (const url of allowedUrls) { + // URL patterns must start with https:// + if (!url.startsWith('https://')) { + logger.error(`URL patterns must start with https:// (got: ${url})`); + logger.error('Use --allow-domains for domain-level filtering without SSL Bump'); + process.exit(1); + } + + // Reject overly broad patterns that would bypass security + const dangerousPatterns = [ + /^https:\/\/\*$/, // https://* + /^https:\/\/\*\.\*$/, // https://*.* + /^https:\/\/\.\*$/, // https://.* + /^\.\*$/, // .* + /^\*$/, // * + /^https:\/\/[^/]*\*[^/]*$/, // https://*anything* without path + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(url)) { + logger.error(`URL pattern "${url}" is too broad and would bypass security controls`); + logger.error( + 'URL patterns must include a specific domain and path, e.g., https://github.com/org/*', + ); + process.exit(1); + } + } + + // Reject characters that could inject Squid config directives or tokens + if (SQUID_DANGEROUS_CHARS.test(url)) { + logger.error( + `URL pattern contains characters unsafe for Squid config: ${JSON.stringify(url)}`, + ); + logger.error( + 'URL patterns must not contain whitespace, quotes, semicolons, backticks, hash characters, or null bytes.', + ); + process.exit(1); + } + + // Ensure pattern has a path component (not just domain) + const urlWithoutScheme = url.replace(/^https:\/\//, ''); + if (!urlWithoutScheme.includes('/')) { + logger.error(`URL pattern "${url}" must include a path component`); + logger.error('For domain-only filtering, use --allow-domains instead'); + logger.error('Example: https://github.com/myorg/* (includes path)'); + process.exit(1); + } + } + } + + // Validate SSL Bump option + if (options.sslBump) { + logger.info('SSL Bump mode enabled - HTTPS content inspection will be performed'); + logger.warn('⚠️ SSL Bump intercepts HTTPS traffic. Only use for trusted workloads.'); + } + + // Log DLP mode + if (options.enableDlp) { + logger.info('DLP scanning enabled - outbound requests will be scanned for credential patterns'); + } + + return { + additionalEnv, + volumeMounts, + allowedUrls, + }; +} diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts new file mode 100644 index 000000000..1afdedc45 --- /dev/null +++ b/src/commands/validators/config-assembly.ts @@ -0,0 +1,252 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import { WrapperConfig } from '../../types'; +import { logger } from '../../logger'; +import { + validateApiProxyConfig, + emitApiProxyTargetWarnings, + emitCliProxyStatusLogs, + warnClassicPATWithCopilotModel, +} from '../../api-proxy-config'; +import { + buildRateLimitConfig, + validateRateLimitFlags, + validateEnableOpenCodeFlag, + validateEnableTokenSteeringFlag, + validateSkipPullWithBuildLocal, + validateAllowHostPorts, + applyHostServicePortsConfig, + applyAgentTimeout, +} from '../../option-parsers'; +import { buildConfig } from '../build-config'; +import { LogAndLimitsResult } from './log-and-limits'; +import { NetworkOptionsResult } from './network-options'; +import { AgentOptionsResult } from './agent-options'; + +/** + * Assembles the {@link WrapperConfig} from pre-validated partial results and + * runs all post-assembly validation guards. + * + * This is the final stage of the validation pipeline. Every input must + * already be validated by the earlier stages; this function only: + * 1. Calls {@link buildConfig} to merge everything into a single object. + * 2. Runs post-config guards that require the fully-assembled config (docker + * host URI format, rate limits, feature-flag compatibility, port rules, + * API-proxy configuration warnings). + * + * Calls `process.exit(1)` on any validation failure so the caller always + * receives a fully-validated, ready-to-use config object. + */ +export function assembleAndValidateConfig( + options: Record, + agentCommand: string, + logAndLimits: LogAndLimitsResult, + networkOptions: NetworkOptionsResult, + agentOptions: AgentOptionsResult, +): WrapperConfig { + // --- Config assembly ----------------------------------------------------- + + const config = buildConfig({ + options, + agentCommand, + logLevel: logAndLimits.logLevel, + allowedDomains: networkOptions.allowedDomains, + blockedDomains: networkOptions.blockedDomains, + localhostDetected: networkOptions.localhostResult.localhostDetected, + additionalEnv: agentOptions.additionalEnv, + volumeMounts: agentOptions.volumeMounts, + upstreamProxy: networkOptions.upstreamProxy, + dnsServers: networkOptions.dnsServers, + dnsOverHttps: networkOptions.dnsOverHttps, + allowedUrls: agentOptions.allowedUrls, + memoryLimit: logAndLimits.memoryLimit, + agentImage: logAndLimits.agentImage, + modelAliases: logAndLimits.modelAliases, + maxEffectiveTokens: logAndLimits.maxEffectiveTokens, + effectiveTokenModelMultipliers: logAndLimits.effectiveTokenModelMultipliers, + maxRuns: logAndLimits.maxRuns, + resolvedCopilotApiTarget: networkOptions.resolvedCopilotApiTarget, + resolvedCopilotApiBasePath: networkOptions.resolvedCopilotApiBasePath, + dockerHostPathPrefix: networkOptions.dockerHostPathPrefixResolution.dockerHostPathPrefix, + }); + + // --- Post-config validations --------------------------------------------- + + // Apply --docker-host override for AWF's own container operations. + // This must be called before startContainers/stopContainers/runAgentCommand. + if (config.awfDockerHost && !config.awfDockerHost.startsWith('unix://')) { + logger.error(`❌ --docker-host must be a unix:// socket URI, got: ${config.awfDockerHost}`); + logger.error(' Example: --docker-host unix:///run/user/1000/docker.sock'); + process.exit(1); + } + if (config.dockerHostPathPrefix && !config.dockerHostPathPrefix.startsWith('/')) { + logger.error( + `❌ --docker-host-path-prefix must be an absolute path, got: ${config.dockerHostPathPrefix}`, + ); + logger.error(' Example: --docker-host-path-prefix /host'); + process.exit(1); + } + + // Parse and validate --agent-timeout + applyAgentTimeout(options.agentTimeout as string | undefined, config, logger); + + // Build rate limit config when API proxy is enabled + if (config.enableApiProxy) { + const rateLimitResult = buildRateLimitConfig(options); + if ('error' in rateLimitResult) { + logger.error(`❌ ${rateLimitResult.error}`); + process.exit(1); + } + config.rateLimitConfig = rateLimitResult.config; + logger.debug( + `Rate limiting: enabled=${rateLimitResult.config.enabled}, rpm=${rateLimitResult.config.rpm}, rph=${rateLimitResult.config.rph}, bytesPm=${rateLimitResult.config.bytesPm}`, + ); + } + + // Error if rate limit flags are used without --enable-api-proxy + const rateLimitFlagValidation = validateRateLimitFlags(config.enableApiProxy ?? false, options); + if (!rateLimitFlagValidation.valid) { + logger.error(rateLimitFlagValidation.error!); + process.exit(1); + } + + // Error if --enable-opencode is used without --enable-api-proxy + const enableOpenCodeValidation = validateEnableOpenCodeFlag( + config.enableApiProxy ?? false, + config.enableOpenCode ?? false, + ); + if (!enableOpenCodeValidation.valid) { + logger.error(enableOpenCodeValidation.error!); + process.exit(1); + } + + // Error if --enable-token-steering is used without --enable-api-proxy + const enableTokenSteeringValidation = validateEnableTokenSteeringFlag( + config.enableApiProxy ?? false, + config.enableTokenSteering ?? false, + ); + if (!enableTokenSteeringValidation.valid) { + logger.error(enableTokenSteeringValidation.error!); + process.exit(1); + } + + // Warn if --env-all is used + if (config.envAll) { + logger.warn('⚠️ Using --env-all: All host environment variables will be passed to container'); + logger.warn(' This may expose sensitive credentials if logs or configs are shared'); + } + + // Log --env-file usage + if (config.envFile) { + logger.debug(`Loading environment variables from file: ${config.envFile}`); + } + + // Validate --allow-host-service-ports (port format & range) + const servicePortsResult = applyHostServicePortsConfig( + config.allowHostServicePorts, + config.enableHostAccess, + logger, + ); + if (!servicePortsResult.valid) { + logger.error(`❌ ${servicePortsResult.error}`); + process.exit(1); + } + config.enableHostAccess = servicePortsResult.enableHostAccess; + + // Validate --allow-host-ports requires --enable-host-access + const hostPortsValidation = validateAllowHostPorts( + config.allowHostPorts, + config.enableHostAccess, + ); + if (!hostPortsValidation.valid) { + logger.error(`❌ ${hostPortsValidation.error}`); + process.exit(1); + } + + // Error if --skip-pull is used with --build-local (incompatible flags) + const skipPullValidation = validateSkipPullWithBuildLocal(config.skipPull, config.buildLocal); + if (!skipPullValidation.valid) { + logger.error(`❌ ${skipPullValidation.error}`); + process.exit(1); + } + + // Warn if --enable-host-access is used with host.docker.internal in allowed domains + if (config.enableHostAccess) { + const hasHostDomain = networkOptions.allowedDomains.some( + (d) => d === 'host.docker.internal' || d.endsWith('.host.docker.internal'), + ); + if (hasHostDomain) { + logger.warn('⚠️ Host access enabled with host.docker.internal in allowed domains'); + logger.warn(' Containers can access ANY service running on the host machine'); + logger.warn(' Only use this for trusted workloads (e.g., MCP gateways)'); + } + } + + // Validate and warn about API proxy configuration + // Pass booleans (not actual keys) to prevent sensitive data flow to logger + const apiProxyValidation = validateApiProxyConfig( + config.enableApiProxy || false, + !!config.openaiApiKey, + !!config.anthropicApiKey, + !!(config.copilotGithubToken || config.copilotApiKey), + !!config.geminiApiKey, + ); + + // Log API proxy status at info level for visibility + if (config.enableApiProxy) { + logger.info( + `API proxy enabled: OpenAI=${!!config.openaiApiKey}, Anthropic=${!!config.anthropicApiKey}, Copilot=${!!(config.copilotGithubToken || config.copilotApiKey)}, Gemini=${!!config.geminiApiKey}`, + ); + } + + for (const warning of apiProxyValidation.warnings) { + logger.warn(warning); + } + for (const msg of apiProxyValidation.debugMessages) { + logger.debug(msg); + } + + // Warn if custom API targets are not in --allow-domains + emitApiProxyTargetWarnings(config, networkOptions.allowedDomains, logger.warn.bind(logger)); + + // Log CLI proxy status + emitCliProxyStatusLogs(config, logger.info.bind(logger), logger.warn.bind(logger)); + + // Warn if a classic PAT is combined with COPILOT_MODEL (Copilot CLI 1.0.21+ incompatibility) + const hasCopilotModelInEnvFiles = (envFile: unknown): boolean => { + const envFiles = Array.isArray(envFile) ? envFile : envFile ? [envFile] : []; + for (const candidate of envFiles) { + if (typeof candidate !== 'string' || candidate.trim() === '') continue; + try { + const envFilePath = path.isAbsolute(candidate) + ? candidate + : path.resolve(process.cwd(), candidate); + const envFileContents = fs.readFileSync(envFilePath, 'utf8'); + for (const line of envFileContents.split(/\r?\n/)) { + const trimmedLine = line.trim(); + if (!trimmedLine || trimmedLine.startsWith('#')) continue; + if (/^(?:export\s+)?COPILOT_MODEL\s*=/.test(trimmedLine)) { + return true; + } + } + } catch { + // Ignore unreadable env files here; this check is only for a pre-flight warning. + } + } + return false; + }; + + // Check if COPILOT_MODEL is set via --env/-e flags, host env (when --env-all is active), or --env-file + const copilotModelFromFlags = !!agentOptions.additionalEnv['COPILOT_MODEL']; + const copilotModelInHostEnv = !!(config.envAll && process.env.COPILOT_MODEL); + const copilotModelInEnvFile = hasCopilotModelInEnvFiles( + (config as { envFile?: unknown }).envFile, + ); + warnClassicPATWithCopilotModel( + config.copilotGithubToken?.startsWith('ghp_') ?? false, + copilotModelFromFlags || copilotModelInHostEnv || copilotModelInEnvFile, + logger.warn.bind(logger), + ); + + return config; +} diff --git a/src/commands/validators/log-and-limits.ts b/src/commands/validators/log-and-limits.ts new file mode 100644 index 000000000..c9749dae5 --- /dev/null +++ b/src/commands/validators/log-and-limits.ts @@ -0,0 +1,136 @@ +import { LogLevel } from '../../types'; +import { logger } from '../../logger'; +import { + validateAnthropicCacheTailTtl, +} from '../../api-proxy-config'; +import { + parseModelMultipliersCli, + parseMemoryLimit, +} from '../../option-parsers'; +import { processAgentImageOption } from '../../domain-utils'; + +/** + * The result produced by {@link validateLogAndLimits}. + */ +export interface LogAndLimitsResult { + logLevel: LogLevel; + modelAliases: Record | undefined; + maxEffectiveTokens: number | undefined; + effectiveTokenModelMultipliers: Record | undefined; + maxRuns: number | undefined; + memoryLimit: string | undefined; + agentImage: string | undefined; +} + +/** + * Validates log-level, model-multiplier, and resource-limit options. + * + * Covers the following option groups: + * - `--log-level` / `logLevel` + * - `--anthropic-cache-tail-ttl` + * - `--max-effective-tokens`, `--max-model-multiplier`, `--max-runs` + * - `--memory-limit`, `--agent-image`, `--build-local` + * + * Calls `process.exit(1)` on any validation failure so the caller always + * receives a fully-validated result. + */ +export function validateLogAndLimits(options: Record): LogAndLimitsResult { + // --- Log level ----------------------------------------------------------- + + const logLevel = options.logLevel as LogLevel; + if (!['debug', 'info', 'warn', 'error'].includes(logLevel)) { + console.error(`Invalid log level: ${logLevel}`); + process.exit(1); + } + + // Validate --anthropic-cache-tail-ttl if provided + validateAnthropicCacheTailTtl(options.anthropicCacheTailTtl as string | undefined); + + // --- Model multipliers --------------------------------------------------- + + // Model aliases may be injected via config file (not a Commander option), + // so access through a Record cast with a proper type annotation. + const modelAliases = (options as Record).modelAliases as + | Record + | undefined; + const maxEffectiveTokensOption = (options as Record).maxEffectiveTokens as + | string + | number + | undefined; + // Config-file multipliers (already a Record) + const configFileMultipliers = (options as Record) + .effectiveTokenModelMultipliers as Record | undefined; + // CLI multipliers via --max-model-multiplier (model:multiplier,... format) + const maxModelMultiplierRaw = (options as Record).maxModelMultiplier as + | string + | undefined; + let cliMultipliers: Record | undefined; + if (maxModelMultiplierRaw !== undefined) { + const parsed = parseModelMultipliersCli(maxModelMultiplierRaw); + if ('error' in parsed) { + console.error(`Error: ${parsed.error}`); + process.exit(1); + } + cliMultipliers = parsed.multipliers; + } + // CLI flag overrides config-file values for the same model name. + const effectiveTokenModelMultipliers = + configFileMultipliers || cliMultipliers + ? { ...configFileMultipliers, ...cliMultipliers } + : undefined; + const maxEffectiveTokens = + maxEffectiveTokensOption !== undefined ? Number(maxEffectiveTokensOption) : undefined; + + if ( + maxEffectiveTokens !== undefined && + (!Number.isInteger(maxEffectiveTokens) || maxEffectiveTokens <= 0) + ) { + console.error('Error: Invalid maxEffectiveTokens value (must be a positive integer)'); + process.exit(1); + } + + const maxRunsOption = (options as Record).maxRuns as + | string + | number + | undefined; + const maxRuns = maxRunsOption !== undefined ? Number(maxRunsOption) : undefined; + + if (maxRuns !== undefined && (!Number.isInteger(maxRuns) || maxRuns <= 0)) { + console.error('Error: Invalid maxRuns value (must be a positive integer)'); + process.exit(1); + } + + logger.setLevel(logLevel); + + // --- Resource limits ----------------------------------------------------- + + // Validate memory limit + const memoryLimit = parseMemoryLimit(options.memoryLimit as string); + if (memoryLimit.error) { + logger.error(memoryLimit.error); + process.exit(1); + } + + // Validate agent image option + const agentImageResult = processAgentImageOption( + options.agentImage as string | undefined, + options.buildLocal as boolean, + ); + if (agentImageResult.error) { + logger.error(agentImageResult.error); + process.exit(1); + } + if (agentImageResult.infoMessage) { + logger.info(agentImageResult.infoMessage); + } + + return { + logLevel, + modelAliases, + maxEffectiveTokens, + effectiveTokenModelMultipliers, + maxRuns, + memoryLimit: memoryLimit.value, + agentImage: agentImageResult.agentImage, + }; +} diff --git a/src/commands/validators/network-options.ts b/src/commands/validators/network-options.ts new file mode 100644 index 000000000..cc38252bd --- /dev/null +++ b/src/commands/validators/network-options.ts @@ -0,0 +1,94 @@ +import { logger } from '../../logger'; +import { UpstreamProxyConfig } from '../../types'; +import { + checkDockerHost, + resolveDockerHostPathPrefix, +} from '../../option-parsers'; +import { resolveAllowedDomains, resolveBlockedDomains } from '../preflight'; +import { resolveNetworkConfig } from '../network-setup'; + +/** + * The result produced by {@link validateNetworkOptions}. + */ +export interface NetworkOptionsResult { + dockerHostCheck: ReturnType; + dockerHostPathPrefixResolution: ReturnType; + allowedDomains: string[]; + blockedDomains: string[]; + localhostResult: ReturnType['localhostResult']; + resolvedCopilotApiTarget: string | undefined; + resolvedCopilotApiBasePath: string | undefined; + upstreamProxy: UpstreamProxyConfig | undefined; + dnsServers: string[]; + dnsOverHttps: string | undefined; +} + +/** + * Validates Docker-host, domain-resolution, and network-configuration options. + * + * Covers the following option groups: + * - `--docker-host` / `DOCKER_HOST` environment variable detection + * - `--docker-host-path-prefix` + * - `--allow-domains`, `--allow-domains-file`, `--block-domains` + * - `--upstream-proxy`, `--dns-servers`, `--dns-over-https` + * + * Emits warnings for external Docker hosts and missing path prefixes but + * does not exit for those cases — hard exits happen only in the delegated + * helpers (`resolveAllowedDomains`, `resolveNetworkConfig`). + */ +export function validateNetworkOptions(options: Record): NetworkOptionsResult { + // --- Docker host --------------------------------------------------------- + + // When DOCKER_HOST points at an external TCP daemon (e.g. workflow-scope DinD), + // AWF redirects its own docker calls to the local socket automatically. + // The original DOCKER_HOST value is forwarded into the agent container so the + // agent workload can still reach the DinD daemon. + const dockerHostCheck = checkDockerHost(); + if (!dockerHostCheck.valid) { + logger.warn( + '⚠️ External DOCKER_HOST detected. AWF will redirect its own Docker calls to the local socket.', + ); + logger.warn( + ' The original DOCKER_HOST (and related Docker client env vars) are forwarded into the agent container.', + ); + } + const dockerHostPathPrefixResolution = resolveDockerHostPathPrefix( + dockerHostCheck, + options.dockerHostPathPrefix as string | undefined, + ); + if (!dockerHostCheck.valid && !dockerHostPathPrefixResolution.dockerHostPathPrefix) { + logger.warn( + '⚠️ If your Docker daemon uses a split runner/daemon filesystem, set --docker-host-path-prefix (for example: /host).', + ); + } + + // --- Domain resolution -------------------------------------------------- + + // Resolve allowed and blocked domains (parse, merge, validate) + const { + allowedDomains, + localhostResult, + resolvedCopilotApiTarget, + resolvedCopilotApiBasePath, + } = resolveAllowedDomains(options); + + const blockedDomains = resolveBlockedDomains(options); + + // --- Network configuration ----------------------------------------------- + + // Resolve network configuration (upstream proxy, DNS servers, DNS-over-HTTPS) + const { upstreamProxy, dnsServers, dnsOverHttps } = resolveNetworkConfig(options); + + return { + dockerHostCheck, + dockerHostPathPrefixResolution, + allowedDomains, + blockedDomains, + localhostResult, + resolvedCopilotApiTarget, + resolvedCopilotApiBasePath, + upstreamProxy, + dnsServers, + dnsOverHttps, + }; +}