266 lines
7.8 KiB
TypeScript
266 lines
7.8 KiB
TypeScript
import type { ConfigScope } from 'src/services/mcp/types.js'
|
|
import type { ZodError, ZodIssue } from 'zod/v4'
|
|
import { jsonParse } from '../slowOperations.js'
|
|
import { plural } from '../stringUtils.js'
|
|
import { validatePermissionRule } from './permissionValidation.js'
|
|
import { generateSettingsJSONSchema } from './schemaOutput.js'
|
|
import type { SettingsJson } from './types.js'
|
|
import { SettingsSchema } from './types.js'
|
|
import { getValidationTip } from './validationTips.js'
|
|
|
|
/**
|
|
* Helper type guards for specific Zod v4 issue types
|
|
* In v4, issue types have different structures than v3
|
|
*/
|
|
function isInvalidTypeIssue(issue: ZodIssue): issue is ZodIssue & {
|
|
code: 'invalid_type'
|
|
expected: string
|
|
input: unknown
|
|
} {
|
|
return issue.code === 'invalid_type'
|
|
}
|
|
|
|
function isInvalidValueIssue(issue: ZodIssue): issue is ZodIssue & {
|
|
code: 'invalid_value'
|
|
values: unknown[]
|
|
input: unknown
|
|
} {
|
|
return issue.code === 'invalid_value'
|
|
}
|
|
|
|
function isUnrecognizedKeysIssue(
|
|
issue: ZodIssue,
|
|
): issue is ZodIssue & { code: 'unrecognized_keys'; keys: string[] } {
|
|
return issue.code === 'unrecognized_keys'
|
|
}
|
|
|
|
function isTooSmallIssue(issue: ZodIssue): issue is ZodIssue & {
|
|
code: 'too_small'
|
|
minimum: number | bigint
|
|
origin: string
|
|
} {
|
|
return issue.code === 'too_small'
|
|
}
|
|
|
|
/** Field path in dot notation (e.g., "permissions.defaultMode", "env.DEBUG") */
|
|
export type FieldPath = string
|
|
|
|
export type ValidationError = {
|
|
/** Relative file path */
|
|
file?: string
|
|
/** Field path in dot notation */
|
|
path: FieldPath
|
|
/** Human-readable error message */
|
|
message: string
|
|
/** Expected value or type */
|
|
expected?: string
|
|
/** The actual invalid value that was provided */
|
|
invalidValue?: unknown
|
|
/** Suggestion for fixing the error */
|
|
suggestion?: string
|
|
/** Link to relevant documentation */
|
|
docLink?: string
|
|
/** MCP-specific metadata - only present for MCP configuration errors */
|
|
mcpErrorMetadata?: {
|
|
/** Which configuration scope this error came from */
|
|
scope: ConfigScope
|
|
/** The server name if error is specific to a server */
|
|
serverName?: string
|
|
/** Severity of the error */
|
|
severity?: 'fatal' | 'warning'
|
|
}
|
|
}
|
|
|
|
export type SettingsWithErrors = {
|
|
settings: SettingsJson
|
|
errors: ValidationError[]
|
|
}
|
|
|
|
/**
|
|
* Format a Zod validation error into human-readable validation errors
|
|
*/
|
|
/**
|
|
* Get the type string for an unknown value (for error messages)
|
|
*/
|
|
function getReceivedType(value: unknown): string {
|
|
if (value === null) return 'null'
|
|
if (value === undefined) return 'undefined'
|
|
if (Array.isArray(value)) return 'array'
|
|
return typeof value
|
|
}
|
|
|
|
function extractReceivedFromMessage(msg: string): string | undefined {
|
|
const match = msg.match(/received (\w+)/)
|
|
return match ? match[1] : undefined
|
|
}
|
|
|
|
export function formatZodError(
|
|
error: ZodError,
|
|
filePath: string,
|
|
): ValidationError[] {
|
|
return error.issues.map((issue): ValidationError => {
|
|
const path = issue.path.map(String).join('.')
|
|
let message = issue.message
|
|
let expected: string | undefined
|
|
|
|
let enumValues: string[] | undefined
|
|
let expectedValue: string | undefined
|
|
let receivedValue: unknown
|
|
let invalidValue: unknown
|
|
|
|
if (isInvalidValueIssue(issue)) {
|
|
enumValues = issue.values.map(v => String(v))
|
|
expectedValue = enumValues.join(' | ')
|
|
receivedValue = undefined
|
|
invalidValue = undefined
|
|
} else if (isInvalidTypeIssue(issue)) {
|
|
expectedValue = issue.expected
|
|
const receivedType = extractReceivedFromMessage(issue.message)
|
|
receivedValue = receivedType ?? getReceivedType(issue.input)
|
|
invalidValue = receivedType ?? getReceivedType(issue.input)
|
|
} else if (isTooSmallIssue(issue)) {
|
|
expectedValue = String(issue.minimum)
|
|
} else if (issue.code === 'custom' && 'params' in issue) {
|
|
const params = issue.params as { received?: unknown }
|
|
receivedValue = params.received
|
|
invalidValue = receivedValue
|
|
}
|
|
|
|
const tip = getValidationTip({
|
|
path,
|
|
code: issue.code,
|
|
expected: expectedValue,
|
|
received: receivedValue,
|
|
enumValues,
|
|
message: issue.message,
|
|
value: receivedValue,
|
|
})
|
|
|
|
if (isInvalidValueIssue(issue)) {
|
|
expected = enumValues?.map(v => `"${v}"`).join(', ')
|
|
message = `Invalid value. Expected one of: ${expected}`
|
|
} else if (isInvalidTypeIssue(issue)) {
|
|
const receivedType =
|
|
extractReceivedFromMessage(issue.message) ??
|
|
getReceivedType(issue.input)
|
|
if (
|
|
issue.expected === 'object' &&
|
|
receivedType === 'null' &&
|
|
path === ''
|
|
) {
|
|
message = 'Invalid or malformed JSON'
|
|
} else {
|
|
message = `Expected ${issue.expected}, but received ${receivedType}`
|
|
}
|
|
} else if (isUnrecognizedKeysIssue(issue)) {
|
|
const keys = issue.keys.join(', ')
|
|
message = `Unrecognized ${plural(issue.keys.length, 'field')}: ${keys}`
|
|
} else if (isTooSmallIssue(issue)) {
|
|
message = `Number must be greater than or equal to ${issue.minimum}`
|
|
expected = String(issue.minimum)
|
|
}
|
|
|
|
return {
|
|
file: filePath,
|
|
path,
|
|
message,
|
|
expected,
|
|
invalidValue,
|
|
suggestion: tip?.suggestion,
|
|
docLink: tip?.docLink,
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Validates that settings file content conforms to the SettingsSchema.
|
|
* This is used during file edits to ensure the resulting file is valid.
|
|
*/
|
|
export function validateSettingsFileContent(content: string):
|
|
| {
|
|
isValid: true
|
|
}
|
|
| {
|
|
isValid: false
|
|
error: string
|
|
fullSchema: string
|
|
} {
|
|
try {
|
|
// Parse the JSON first
|
|
const jsonData = jsonParse(content)
|
|
|
|
// Validate against SettingsSchema in strict mode
|
|
const result = SettingsSchema().strict().safeParse(jsonData)
|
|
|
|
if (result.success) {
|
|
return { isValid: true }
|
|
}
|
|
|
|
// Format the validation error in a helpful way
|
|
const errors = formatZodError(result.error, 'settings')
|
|
const errorMessage =
|
|
'Settings validation failed:\n' +
|
|
errors.map(err => `- ${err.path}: ${err.message}`).join('\n')
|
|
|
|
return {
|
|
isValid: false,
|
|
error: errorMessage,
|
|
fullSchema: generateSettingsJSONSchema(),
|
|
}
|
|
} catch (parseError) {
|
|
return {
|
|
isValid: false,
|
|
error: `Invalid JSON: ${parseError instanceof Error ? parseError.message : 'Unknown parsing error'}`,
|
|
fullSchema: generateSettingsJSONSchema(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filters invalid permission rules from raw parsed JSON data before schema validation.
|
|
* This prevents one bad rule from poisoning the entire settings file.
|
|
* Returns warnings for each filtered rule.
|
|
*/
|
|
export function filterInvalidPermissionRules(
|
|
data: unknown,
|
|
filePath: string,
|
|
): ValidationError[] {
|
|
if (!data || typeof data !== 'object') return []
|
|
const obj = data as Record<string, unknown>
|
|
if (!obj.permissions || typeof obj.permissions !== 'object') return []
|
|
const perms = obj.permissions as Record<string, unknown>
|
|
|
|
const warnings: ValidationError[] = []
|
|
for (const key of ['allow', 'deny', 'ask']) {
|
|
const rules = perms[key]
|
|
if (!Array.isArray(rules)) continue
|
|
|
|
perms[key] = rules.filter(rule => {
|
|
if (typeof rule !== 'string') {
|
|
warnings.push({
|
|
file: filePath,
|
|
path: `permissions.${key}`,
|
|
message: `Non-string value in ${key} array was removed`,
|
|
invalidValue: rule,
|
|
})
|
|
return false
|
|
}
|
|
const result = validatePermissionRule(rule)
|
|
if (!result.valid) {
|
|
let message = `Invalid permission rule "${rule}" was skipped`
|
|
if (result.error) message += `: ${result.error}`
|
|
if (result.suggestion) message += `. ${result.suggestion}`
|
|
warnings.push({
|
|
file: filePath,
|
|
path: `permissions.${key}`,
|
|
message,
|
|
invalidValue: rule,
|
|
})
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
return warnings
|
|
}
|