diff --git a/source/agent-config.js b/source/agent-config.js new file mode 100644 index 00000000..f49a18d5 --- /dev/null +++ b/source/agent-config.js @@ -0,0 +1,108 @@ +import { readFile } from 'fs/promises'; +import { z } from 'zod'; +import { createError } from 'error-causes'; +import { ValidationError } from './ai-errors.js'; +import { parseOpenCodeNDJSON } from './agent-parser.js'; + +/** + * Format Zod validation errors into a human-readable message. + * @param {any} zodError - Zod validation error + * @returns {string} Formatted error message + */ +export const formatZodError = (zodError) => { + const issues = zodError.issues || zodError.errors; + return issues + ? issues.map(e => `${e.path.join('.')}: ${e.message}`).join('; ') + : zodError.message || 'Validation failed'; +}; + +/** + * Get agent configuration based on agent name. + * Supports 'claude', 'opencode', and 'cursor' agents. + * All agents use their standard OAuth authentication flows. + * @param {string} agentName - Name of the agent ('claude', 'opencode', 'cursor') + * @returns {Object} Agent configuration with command and args + */ +export const getAgentConfig = (agentName = 'claude') => { + const agentConfigs = { + claude: { + command: 'claude', + args: ['-p', '--output-format', 'json', '--no-session-persistence'] + }, + opencode: { + command: 'opencode', + args: ['run', '--format', 'json'], + parseOutput: (stdout, logger) => parseOpenCodeNDJSON(stdout, logger) + }, + cursor: { + command: 'agent', + args: ['--print', '--output-format', 'json', '--trust'] + } + }; + + const config = agentConfigs[agentName.toLowerCase()]; + if (!config) { + throw createError({ + ...ValidationError, + message: `Unknown agent: ${agentName}. Supported agents: ${Object.keys(agentConfigs).join(', ')}` + }); + } + + return config; +}; + +// YAGNI: only command + args — parseOutput is a runtime function, not a serializable config field +const agentConfigFileSchema = z.object({ + command: z.string().min(1, { error: 'command is required' }), + args: z.array(z.string()).default([]) +}); + +const readAgentConfigFile = async ({ configPath }) => { + try { + return await readFile(configPath, 'utf-8'); + } catch (err) { + throw createError({ + ...ValidationError, + message: `Failed to read agent config file: ${configPath}`, + code: 'AGENT_CONFIG_READ_ERROR', + cause: err + }); + } +}; + +const parseJson = ({ configPath, raw }) => { + try { + return JSON.parse(raw); + } catch (err) { + throw createError({ + ...ValidationError, + message: `Agent config file is not valid JSON: ${configPath}`, + code: 'AGENT_CONFIG_PARSE_ERROR', + cause: err + }); + } +}; + +const validateAgentConfig = (parsed) => { + try { + return agentConfigFileSchema.parse(parsed); + } catch (zodError) { + throw createError({ + ...ValidationError, + message: `Invalid agent config: ${formatZodError(zodError)}`, + code: 'AGENT_CONFIG_VALIDATION_ERROR', + cause: zodError + }); + } +}; + +/** + * Load and validate an agent configuration from a JSON file. + * @param {string} configPath - Path to the JSON config file + * @returns {Promise} Validated agent config with command and args + */ +export const loadAgentConfig = async (configPath) => { + const raw = await readAgentConfigFile({ configPath }); + const parsed = parseJson({ configPath, raw }); + return validateAgentConfig(parsed); +}; diff --git a/source/agent-config.test.js b/source/agent-config.test.js new file mode 100644 index 00000000..964f9ded --- /dev/null +++ b/source/agent-config.test.js @@ -0,0 +1,295 @@ +import { describe, test } from 'vitest'; +import { assert } from './vitest.js'; +import { Try } from './riteway.js'; +import { formatZodError, getAgentConfig, loadAgentConfig } from './agent-config.js'; + +describe('formatZodError()', () => { + test('formats a single issue', () => { + const zodError = { issues: [{ path: ['command'], message: 'required' }] }; + + assert({ + given: 'an error with a single issue', + should: 'format as "field: message"', + actual: formatZodError(zodError), + expected: 'command: required' + }); + }); + + test('joins multiple issues with "; "', () => { + const zodError = { + issues: [ + { path: ['command'], message: 'required' }, + { path: ['args', '0'], message: 'must be string' } + ] + }; + + assert({ + given: 'an error with multiple issues', + should: 'join all formatted issues with "; "', + actual: formatZodError(zodError), + expected: 'command: required; args.0: must be string' + }); + }); + + test('falls back to message when no issues array', () => { + const zodError = { message: 'something went wrong' }; + + assert({ + given: 'an error with no issues but a message property', + should: 'return the message', + actual: formatZodError(zodError), + expected: 'something went wrong' + }); + }); + + test('falls back to "Validation failed" when no issues or message', () => { + const zodError = {}; + + assert({ + given: 'an error with neither issues nor message', + should: 'return the default fallback message', + actual: formatZodError(zodError), + expected: 'Validation failed' + }); + }); +}); + +describe('getAgentConfig()', () => { + test('returns claude configuration for "claude" agent', () => { + const config = getAgentConfig('claude'); + + assert({ + given: 'agent name "claude"', + should: 'return command "claude"', + actual: config.command, + expected: 'claude' + }); + + assert({ + given: 'agent name "claude"', + should: 'return correct args array', + actual: config.args, + expected: ['-p', '--output-format', 'json', '--no-session-persistence'] + }); + }); + + test('returns opencode configuration with parseOutput function', () => { + const config = getAgentConfig('opencode'); + + assert({ + given: 'agent name "opencode"', + should: 'return command "opencode"', + actual: config.command, + expected: 'opencode' + }); + + assert({ + given: 'agent name "opencode"', + should: 'return correct args array', + actual: config.args, + expected: ['run', '--format', 'json'] + }); + + assert({ + given: 'agent name "opencode"', + should: 'provide parseOutput function', + actual: typeof config.parseOutput, + expected: 'function' + }); + }); + + test('returns cursor configuration for "cursor" agent', () => { + const config = getAgentConfig('cursor'); + + assert({ + given: 'agent name "cursor"', + should: 'return command "agent"', + actual: config.command, + expected: 'agent' + }); + + assert({ + given: 'agent name "cursor"', + should: 'return args including --trust flag for non-interactive execution', + actual: config.args, + expected: ['--print', '--output-format', 'json', '--trust'] + }); + }); + + test('returns default claude configuration when no agent name provided', () => { + const config = getAgentConfig(); + + assert({ + given: 'no agent name', + should: 'default to claude command', + actual: config.command, + expected: 'claude' + }); + + assert({ + given: 'no agent name', + should: 'return correct args array', + actual: config.args, + expected: ['-p', '--output-format', 'json', '--no-session-persistence'] + }); + }); + + test('handles case-insensitive agent names', () => { + const config = getAgentConfig('OpenCode'); + + assert({ + given: 'mixed-case "OpenCode"', + should: 'normalize to "opencode" command', + actual: config.command, + expected: 'opencode' + }); + + assert({ + given: 'mixed-case "OpenCode"', + should: 'return correct args array', + actual: config.args, + expected: ['run', '--format', 'json'] + }); + + assert({ + given: 'mixed-case "OpenCode"', + should: 'provide parseOutput function', + actual: typeof config.parseOutput, + expected: 'function' + }); + }); + + test('throws ValidationError for invalid agent name', () => { + const error = Try(getAgentConfig, 'invalid-agent'); + + assert({ + given: 'invalid agent name', + should: 'throw Error with cause', + actual: error instanceof Error && error.cause !== undefined, + expected: true + }); + + assert({ + given: 'invalid agent name', + should: 'have ValidationError name in cause', + actual: error?.cause?.name, + expected: 'ValidationError' + }); + + assert({ + given: 'invalid agent name', + should: 'mention "claude" in error message', + actual: error?.cause?.message?.includes('claude'), + expected: true + }); + + assert({ + given: 'invalid agent name', + should: 'mention "opencode" in error message', + actual: error?.cause?.message?.includes('opencode'), + expected: true + }); + + assert({ + given: 'invalid agent name', + should: 'mention "cursor" in error message', + actual: error?.cause?.message?.includes('cursor'), + expected: true + }); + }); +}); + +describe('loadAgentConfig()', () => { + test('loads and parses valid agent config JSON file', async () => { + const config = await loadAgentConfig('./source/fixtures/test-agent-config.json'); + + assert({ + given: 'valid agent config JSON file', + should: 'return command "my-agent"', + actual: config.command, + expected: 'my-agent' + }); + + assert({ + given: 'valid agent config JSON file', + should: 'return correct args array', + actual: JSON.stringify(config.args), + expected: JSON.stringify(['--print', '--format', 'json']) + }); + }); + + test('throws ValidationError with AGENT_CONFIG_PARSE_ERROR for invalid JSON', async () => { + const error = await Try(loadAgentConfig, './source/fixtures/invalid-agent-config.txt'); + + assert({ + given: 'invalid JSON file', + should: 'throw Error with cause', + actual: error instanceof Error && error.cause !== undefined, + expected: true + }); + + assert({ + given: 'invalid JSON file', + should: 'have ValidationError name in cause', + actual: error?.cause?.name, + expected: 'ValidationError' + }); + + assert({ + given: 'invalid JSON file', + should: 'have AGENT_CONFIG_PARSE_ERROR code in cause', + actual: error?.cause?.code, + expected: 'AGENT_CONFIG_PARSE_ERROR' + }); + }); + + test('throws ValidationError with AGENT_CONFIG_VALIDATION_ERROR when command field missing', async () => { + const error = await Try(loadAgentConfig, './source/fixtures/no-command-agent-config.json'); + + assert({ + given: 'config file missing command field', + should: 'throw Error with cause', + actual: error instanceof Error && error.cause !== undefined, + expected: true + }); + + assert({ + given: 'config file missing command field', + should: 'have ValidationError name in cause', + actual: error?.cause?.name, + expected: 'ValidationError' + }); + + assert({ + given: 'config file missing command field', + should: 'have AGENT_CONFIG_VALIDATION_ERROR code in cause', + actual: error?.cause?.code, + expected: 'AGENT_CONFIG_VALIDATION_ERROR' + }); + }); + + test('throws ValidationError with AGENT_CONFIG_READ_ERROR for nonexistent file', async () => { + const error = await Try(loadAgentConfig, './nonexistent/path.json'); + + assert({ + given: 'nonexistent file path', + should: 'throw Error with cause', + actual: error instanceof Error && error.cause !== undefined, + expected: true + }); + + assert({ + given: 'nonexistent file path', + should: 'have ValidationError name in cause', + actual: error?.cause?.name, + expected: 'ValidationError' + }); + + assert({ + given: 'nonexistent file path', + should: 'have AGENT_CONFIG_READ_ERROR code in cause', + actual: error?.cause?.code, + expected: 'AGENT_CONFIG_READ_ERROR' + }); + }); +}); diff --git a/source/fixtures/invalid-agent-config.txt b/source/fixtures/invalid-agent-config.txt new file mode 100644 index 00000000..5a64e348 --- /dev/null +++ b/source/fixtures/invalid-agent-config.txt @@ -0,0 +1 @@ +this is not valid json {{{ \ No newline at end of file diff --git a/source/fixtures/no-command-agent-config.json b/source/fixtures/no-command-agent-config.json new file mode 100644 index 00000000..9d37e243 --- /dev/null +++ b/source/fixtures/no-command-agent-config.json @@ -0,0 +1,3 @@ +{ + "args": ["--print"] +} diff --git a/source/fixtures/test-agent-config.json b/source/fixtures/test-agent-config.json new file mode 100644 index 00000000..6e698b20 --- /dev/null +++ b/source/fixtures/test-agent-config.json @@ -0,0 +1,4 @@ +{ + "command": "my-agent", + "args": ["--print", "--format", "json"] +} diff --git a/source/validation.js b/source/validation.js new file mode 100644 index 00000000..7c680100 --- /dev/null +++ b/source/validation.js @@ -0,0 +1,68 @@ +import { resolve, relative } from 'path'; +import { createError } from 'error-causes'; +import { createDebugLogger } from './debug-logger.js'; +import { SecurityError } from './ai-errors.js'; + +/** + * Validate that a file path does not escape the base directory. + * @param {string} filePath - Path to validate + * @param {string} baseDir - Base directory to restrict paths to + * @returns {string} Resolved absolute path + * @throws {Error} If path escapes the base directory + */ +export const validateFilePath = (filePath, baseDir) => { + const resolved = resolve(baseDir, filePath); + const rel = relative(baseDir, resolved); + if (rel.startsWith('..')) { + throw createError({ + ...SecurityError, + message: 'File path escapes base directory', + code: 'PATH_TRAVERSAL', + filePath, + baseDir + }); + } + return resolved; +}; + +/** + * Verify that an agent is properly configured and authenticated. + * Performs a minimal smoke test by sending a simple prompt and checking for valid response. + * @param {Object} options + * @param {Object} options.agentConfig - Agent configuration + * @param {string} options.agentConfig.command - Command to execute + * @param {Array} [options.agentConfig.args=[]] - Command arguments + * @param {Function} options.executeAgent - Function to execute agent commands + * @param {number} [options.timeout=30000] - Timeout in milliseconds (default: 30 seconds) + * @param {boolean} [options.debug=false] - Enable debug logging + * @returns {Promise} Result object with success boolean and optional error message + */ +export const verifyAgentAuthentication = async ({ agentConfig, executeAgent, timeout = 30000, debug = false }) => { + const logger = createDebugLogger({ debug }); + + logger.log('Verifying agent authentication...'); + logger.command(agentConfig.command, agentConfig.args); + + try { + // Simple smoke test prompt that should work with any agent + const testPrompt = 'Respond with valid JSON: {"status": "ok"}'; + + await executeAgent({ + agentConfig, + prompt: testPrompt, + timeout, + debug: false // Don't clutter output during smoke test + }); + + logger.log('Agent authentication verified successfully'); + return { success: true }; + } catch (err) { + logger.log('Agent authentication failed:', err.message); + + // Provide helpful error message with authentication guidance + // Since this function's purpose IS auth verification, any failure warrants guidance + const errorMessage = `${err.message}\n\nšŸ’” Agent authentication required. Run the appropriate setup command:\n - Claude: "claude setup-token" - https://docs.anthropic.com/en/docs/claude-code\n - Cursor: "agent login" - https://docs.cursor.com/context/rules-for-ai\n - OpenCode: See https://opencode.ai/docs/cli/ for authentication setup`; + + return { success: false, error: errorMessage }; + } +}; diff --git a/source/validation.test.js b/source/validation.test.js new file mode 100644 index 00000000..7de06617 --- /dev/null +++ b/source/validation.test.js @@ -0,0 +1,177 @@ +import { describe, test } from 'vitest'; +import { assert } from './vitest.js'; +import { Try } from './riteway.js'; +import { + validateFilePath, + verifyAgentAuthentication +} from './validation.js'; + +describe('validation', () => { + describe('validateFilePath()', () => { + test('accepts paths within base directory', () => { + const baseDir = '/home/user/project'; + + assert({ + given: 'a path within the base directory', + should: 'return the resolved absolute path', + actual: validateFilePath('tests/test.sudo', baseDir), + expected: '/home/user/project/tests/test.sudo' + }); + }); + + test('accepts absolute paths within base directory', () => { + const baseDir = '/home/user/project'; + + assert({ + given: 'an absolute path within the base directory', + should: 'return the resolved absolute path', + actual: validateFilePath('/home/user/project/tests/test.sudo', baseDir), + expected: '/home/user/project/tests/test.sudo' + }); + }); + + test('rejects path traversal attempts', () => { + const baseDir = '/home/user/project'; + + const error = Try(validateFilePath, '../../etc/passwd', baseDir); + + assert({ + given: 'a path that escapes the base directory', + should: 'throw an error with message', + actual: error?.message, + expected: 'File path escapes base directory' + }); + + assert({ + given: 'a path that escapes the base directory', + should: 'have SecurityError name in cause', + actual: error?.cause?.name, + expected: 'SecurityError' + }); + + assert({ + given: 'a path that escapes the base directory', + should: 'have PATH_TRAVERSAL code in cause', + actual: error?.cause?.code, + expected: 'PATH_TRAVERSAL' + }); + }); + + test('rejects absolute path outside base directory', () => { + const baseDir = '/home/user/project'; + + const error = Try(validateFilePath, '/etc/passwd', baseDir); + + assert({ + given: 'an absolute path outside the base directory', + should: 'throw an error with message', + actual: error?.message, + expected: 'File path escapes base directory' + }); + + assert({ + given: 'an absolute path outside the base directory', + should: 'have SecurityError name in cause', + actual: error?.cause?.name, + expected: 'SecurityError' + }); + + assert({ + given: 'an absolute path outside the base directory', + should: 'have PATH_TRAVERSAL code in cause', + actual: error?.cause?.code, + expected: 'PATH_TRAVERSAL' + }); + }); + }); + + describe('verifyAgentAuthentication()', () => { + // Mock executeAgent function for testing + const createMockExecuteAgent = ({ shouldSucceed = true, errorMessage = 'Authentication failed' } = {}) => { + return async () => { + if (!shouldSucceed) { + throw new Error(errorMessage); + } + return { status: 'ok' }; + }; + }; + + test('succeeds when agent returns valid JSON', async () => { + const executeAgent = createMockExecuteAgent({ shouldSucceed: true }); + const agentConfig = { + command: 'mock-agent', + args: [] + }; + + const result = await verifyAgentAuthentication({ agentConfig, executeAgent }); + + assert({ + given: 'agent returning valid JSON', + should: 'return success true', + actual: result.success, + expected: true + }); + }); + + test('fails when agent throws error', async () => { + const executeAgent = createMockExecuteAgent({ shouldSucceed: false, errorMessage: 'Process failed' }); + const agentConfig = { + command: 'mock-agent', + args: [] + }; + + const result = await verifyAgentAuthentication({ agentConfig, executeAgent, timeout: 1000 }); + + assert({ + given: 'agent throwing error', + should: 'return success false', + actual: result.success, + expected: false + }); + + assert({ + given: 'agent authentication failure', + should: 'include error message', + actual: result.error !== undefined, + expected: true + }); + }); + + test('provides helpful error message for authentication errors', async () => { + const executeAgent = createMockExecuteAgent({ + shouldSucceed: false, + errorMessage: 'Process failed' + }); + const agentConfig = { + command: 'mock-agent', + args: [] + }; + + const result = await verifyAgentAuthentication({ agentConfig, executeAgent, timeout: 1000 }); + + assert({ + given: 'any error during verification', + should: 'include helpful guidance', + actual: result.error.includes('Agent authentication required'), + expected: true + }); + }); + + test('succeeds without explicit timeout argument', async () => { + const executeAgent = createMockExecuteAgent({ shouldSucceed: true }); + const agentConfig = { + command: 'mock-agent', + args: [] + }; + + const result = await verifyAgentAuthentication({ agentConfig, executeAgent }); + + assert({ + given: 'no timeout specified', + should: 'complete successfully with default timeout', + actual: result.success, + expected: true + }); + }); + }); +});