// 宿主自定义工具的声明编译器:扫描 /ai-tools/*.tool.yaml。 // // 为什么注册载体固定是 YAML(设计裁决,见 soma-ai/doc/soma AI 链详设.md §2.6): // 工具注册的主体是给模型看的描述与给用户看的文案,这类元数据沉在代码里难管理、 // 难 review,平台也无法"只读不执行"地列出工具面。执行体才允许是代码。 // // 扫描纪律全套复用 AiApiRegistry.compile:文件名排序、拒符号链接、拒重名、 // 越界防护。坏声明一律编译期报错——带病上线的工具会在对话中途以模型看不懂的 // 方式失败,那比启动就报错难查十倍。 import fs from 'node:fs'; import path from 'node:path'; import yaml from 'js-yaml'; import { AiApiError, AiErrorCodes } from '../../ai-shared/AiError'; import { ASSISTANT_TOOL_NAME_PATTERN } from './AssistantToolRegistry'; import type { AssistantToolDisplay } from './AssistantToolkit'; export const ASSISTANT_TOOLS_DIR = 'ai-tools'; const DECLARATION_SUFFIX = '.tool.yaml'; const HANDLER_EXTENSIONS = new Set(['.ts', '.mts', '.js', '.mjs']); /** 助手/生成/函数三条自家链路禁止被声明式工具回调:前两者防递归,后者由 ai-api 桥独占。 */ const FORBIDDEN_SERVICE_PREFIXES = ['/api/ai-assistant/', '/api/ai-page/', '/api/ai-api/']; export type AssistantToolInputType = 'string' | 'number' | 'boolean' | 'enum'; export interface AssistantToolInputField { type: AssistantToolInputType; desc: string; required: boolean; values?: string[]; } export interface AssistantToolServiceCall { kind: 'service'; service: string; method: 'GET' | 'POST'; timeoutMs?: number; } export interface AssistantToolHandlerCall { kind: 'handler'; /** 已解析并做过 containment 校验的绝对路径。 */ handlerFile: string; timeoutMs?: number; } export interface AssistantToolDeclaration { tool: string; desc: string; input: Record; call: AssistantToolServiceCall | AssistantToolHandlerCall; display?: AssistantToolDisplay; sourceFile: string; } export function compileAssistantToolDeclarations(hostDir: string): AssistantToolDeclaration[] { const toolsRoot = path.join(path.resolve(hostDir), ASSISTANT_TOOLS_DIR); if (!fs.existsSync(toolsRoot)) { return []; } const realToolsRoot = realDirectory(toolsRoot); const declarations: AssistantToolDeclaration[] = []; const seen = new Set(); const entries = fs.readdirSync(realToolsRoot, { withFileTypes: true }) .filter((entry) => entry.name.endsWith(DECLARATION_SUFFIX)) .sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { const sourceFile = path.join(realToolsRoot, entry.name); if (entry.isSymbolicLink()) { throw declarationError(sourceFile, '符号链接不允许作为工具声明'); } if (!entry.isFile()) { continue; } const declaration = parseDeclaration(sourceFile, realToolsRoot); if (seen.has(declaration.tool)) { throw declarationError(sourceFile, `工具重名:${declaration.tool}`); } seen.add(declaration.tool); declarations.push(declaration); } return declarations; } function parseDeclaration(sourceFile: string, toolsRoot: string): AssistantToolDeclaration { const source = readYamlObject(sourceFile); const allowed = new Set(['tool', 'desc', 'in', 'call', 'display']); const unknown = Object.keys(source).filter((key) => !allowed.has(key)); if (unknown.length > 0) { throw declarationError(sourceFile, `存在未知字段:${unknown.join(', ')}`); } const tool = readString(source.tool); if (!tool || !ASSISTANT_TOOL_NAME_PATTERN.test(tool)) { throw declarationError(sourceFile, 'tool 名称不合法(字母开头,字母/数字/_/- 共 1–64 位)'); } const desc = readString(source.desc); if (!desc) { throw declarationError(sourceFile, 'desc 必须是非空字符串(模型据此决定何时调用)'); } return { tool, desc, input: parseInput(source.in, sourceFile), call: parseCall(source.call, sourceFile, toolsRoot), ...(parseDisplay(source.display, sourceFile) ?? {}), sourceFile, }; } function parseInput(value: unknown, sourceFile: string): Record { if (value === undefined || value === null) { return {}; } if (!isRecord(value)) { throw declarationError(sourceFile, 'in 必须是对象'); } const fields: Record = {}; for (const [name, raw] of Object.entries(value)) { if (!/^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(name)) { throw declarationError(sourceFile, `入参名不合法:${name}`); } if (!isRecord(raw)) { throw declarationError(sourceFile, `in.${name} 必须是对象`); } const allowed = new Set(['type', 'desc', 'required', 'values']); const unknown = Object.keys(raw).filter((key) => !allowed.has(key)); if (unknown.length > 0) { throw declarationError(sourceFile, `in.${name} 存在未知字段:${unknown.join(', ')}`); } const type = readString(raw.type) ?? 'string'; if (!isInputType(type)) { throw declarationError(sourceFile, `in.${name}.type 只支持 string/number/boolean/enum`); } const desc = readString(raw.desc); if (!desc) { throw declarationError(sourceFile, `in.${name}.desc 必须是非空字符串`); } if (raw.required !== undefined && typeof raw.required !== 'boolean') { throw declarationError(sourceFile, `in.${name}.required 必须是布尔值`); } const values = parseEnumValues(raw.values, name, type, sourceFile); fields[name] = { type, desc, required: raw.required ?? true, ...(values ? { values } : {}), }; } return fields; } function parseEnumValues( raw: unknown, name: string, type: AssistantToolInputType, sourceFile: string, ): string[] | undefined { if (type !== 'enum') { if (raw !== undefined) { throw declarationError(sourceFile, `in.${name}.values 仅 type: enum 可用`); } return undefined; } if (!Array.isArray(raw) || raw.length === 0) { throw declarationError(sourceFile, `in.${name}.values 必须是非空字符串数组`); } const values = raw.map((item) => readString(item)); if (values.some((item) => !item)) { throw declarationError(sourceFile, `in.${name}.values 必须是非空字符串数组`); } return values as string[]; } function parseCall( value: unknown, sourceFile: string, toolsRoot: string, ): AssistantToolServiceCall | AssistantToolHandlerCall { if (!isRecord(value)) { throw declarationError(sourceFile, 'call 必须是对象'); } const allowed = new Set(['service', 'method', 'handler', 'timeoutMs']); const unknown = Object.keys(value).filter((key) => !allowed.has(key)); if (unknown.length > 0) { throw declarationError(sourceFile, `call 存在未知字段:${unknown.join(', ')}`); } const service = readString(value.service); const handler = readString(value.handler); if (Boolean(service) === Boolean(handler)) { throw declarationError(sourceFile, 'call 必须且只能声明 service 或 handler 其一'); } const timeoutMs = parseTimeout(value.timeoutMs, sourceFile); if (handler) { if (value.method !== undefined) { throw declarationError(sourceFile, 'call.method 仅 call.service 可用'); } return { kind: 'handler', handlerFile: resolveHandlerFile(handler, sourceFile, toolsRoot), ...(timeoutMs ? { timeoutMs } : {}), }; } return { kind: 'service', service: validateServicePath(service as string, sourceFile), method: parseMethod(value.method, sourceFile), ...(timeoutMs ? { timeoutMs } : {}), }; } /** 只允许回调宿主自身的 /api 路径:声明式工具是"把已有接口开给助手",不是通用出站客户端。 */ function validateServicePath(service: string, sourceFile: string): string { if (!service.startsWith('/api/')) { throw declarationError(sourceFile, 'call.service 必须是宿主自身以 /api/ 开头的路径'); } if (service.includes('?') || service.includes('#') || service.includes('\\')) { throw declarationError(sourceFile, 'call.service 不允许带查询串、片段或反斜杠'); } if (service.split('/').includes('..')) { throw declarationError(sourceFile, 'call.service 不允许包含 ..'); } const forbidden = FORBIDDEN_SERVICE_PREFIXES.find((prefix) => service.startsWith(prefix)); if (forbidden) { throw declarationError( sourceFile, `call.service 不允许指向 ${forbidden}*(助手与生成链防递归,AI 函数只经 ai-api 桥暴露)`, ); } return service; } function resolveHandlerFile(handler: string, sourceFile: string, toolsRoot: string): string { if (path.isAbsolute(handler)) { throw declarationError(sourceFile, 'call.handler 必须是相对本声明文件的路径'); } const resolved = path.resolve(path.dirname(sourceFile), handler); if (!isContained(toolsRoot, resolved)) { throw declarationError(sourceFile, `call.handler 越出 ${ASSISTANT_TOOLS_DIR}/ 目录`); } if (!HANDLER_EXTENSIONS.has(path.extname(resolved).toLowerCase())) { throw declarationError(sourceFile, 'call.handler 扩展名必须是 .ts/.mts/.js/.mjs'); } let realPath: string; try { realPath = fs.realpathSync(resolved); if (!fs.statSync(realPath).isFile()) { throw new Error('not a file'); } } catch { throw declarationError(sourceFile, `call.handler 指向的文件不存在:${handler}`); } // 符号链接逃逸与 dep 同纪律:真实路径也必须落在 ai-tools/ 内。 if (!isContained(realDirectory(toolsRoot), realPath)) { throw declarationError(sourceFile, `call.handler 符号链接越出 ${ASSISTANT_TOOLS_DIR}/ 目录`); } return realPath; } function parseMethod(value: unknown, sourceFile: string): 'GET' | 'POST' { if (value === undefined || value === null) { return 'POST'; } const method = readString(value)?.toUpperCase(); if (method !== 'GET' && method !== 'POST') { throw declarationError(sourceFile, 'call.method 只支持 GET 或 POST'); } return method; } function parseTimeout(value: unknown, sourceFile: string): number | undefined { if (value === undefined || value === null) { return undefined; } if (!Number.isSafeInteger(value) || Number(value) < 1) { throw declarationError(sourceFile, 'call.timeoutMs 必须是正整数'); } return Number(value); } function parseDisplay( value: unknown, sourceFile: string, ): { display: AssistantToolDisplay } | undefined { if (value === undefined || value === null) { return undefined; } if (!isRecord(value)) { throw declarationError(sourceFile, 'display 必须是对象'); } const keys = ['pending', 'done', 'error'] as const; const unknown = Object.keys(value).filter((key) => !keys.includes(key as never)); if (unknown.length > 0) { throw declarationError(sourceFile, `display 存在未知字段:${unknown.join(', ')}`); } const display = {} as AssistantToolDisplay; for (const key of keys) { const text = readString(value[key]); if (!text) { throw declarationError(sourceFile, `display.${key} 必须是非空字符串`); } display[key] = text; } return { display }; } function readYamlObject(sourceFile: string): Record { let parsed: unknown; try { parsed = yaml.load(fs.readFileSync(sourceFile, 'utf8')); } catch (error) { throw declarationError(sourceFile, error instanceof Error ? error.message : 'YAML 无法读取'); } if (!isRecord(parsed)) { throw declarationError(sourceFile, '声明顶层必须是对象'); } return parsed; } function realDirectory(candidate: string): string { try { const realPath = fs.realpathSync(candidate); if (!fs.statSync(realPath).isDirectory()) { throw new Error('not a directory'); } return realPath; } catch { throw new AiApiError( AiErrorCodes.AI_TOOL_DECL_INVALID, `${ASSISTANT_TOOLS_DIR}/ 不是目录或无法读取:${candidate}`, ); } } function isInputType(value: string): value is AssistantToolInputType { return value === 'string' || value === 'number' || value === 'boolean' || value === 'enum'; } function readString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function isContained(root: string, target: string): boolean { const relative = path.relative(path.resolve(root), path.resolve(target)); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } function declarationError(sourceFile: string, message: string): AiApiError { return new AiApiError( AiErrorCodes.AI_TOOL_DECL_INVALID, `${path.basename(sourceFile)}:${message}`, ); }