/** * Shared tool execution logic used by both the Catty Agent executor (switch/case) * and the Vercel AI SDK tool wrappers. * * Each function encapsulates the core business logic for a tool — validation, * safety checks, bridge calls, and result formatting — so callers only need to * adapt the return value to their own response shape. */ import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor'; import type { AIPermissionMode, WebSearchConfig } from '../types'; import { checkCommandSafety, checkCommandSafetyCommonOnly } from '../cattyAgent/safety'; import { executeWebSearchProvider } from './webSearchProviders'; // --------------------------------------------------------------------------- // Shared result types // --------------------------------------------------------------------------- /** Discriminated union returned by every shared executor. */ export type ToolExecResult = | { ok: true; data: T } | { ok: false; error: string; data?: T }; // --------------------------------------------------------------------------- // Dependencies bundle // --------------------------------------------------------------------------- export interface ToolDeps { bridge: NetcattyBridge; context: ExecutorContext | (() => ExecutorContext); commandBlocklist?: string[]; permissionMode: AIPermissionMode; webSearchConfig?: WebSearchConfig; chatSessionId?: string; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function resolveContext(ctx: ToolDeps['context']): ExecutorContext { return typeof ctx === 'function' ? ctx() : ctx; } function validSessionIds(ctx: ToolDeps['context']): Set { const resolved = resolveContext(ctx); return new Set(resolved.sessions.map(s => s.sessionId)); } function validateSessionScope(ctx: ToolDeps['context'], sessionId: string): string | null { const ids = validSessionIds(ctx); if (!ids.has(sessionId)) { return `Session "${sessionId}" is not in the current scope. Available sessions: ${[...ids].join(', ')}`; } return null; } function isObserver(mode: AIPermissionMode): boolean { return mode === 'observer'; } // --------------------------------------------------------------------------- // Tool executors // --------------------------------------------------------------------------- export async function executeTerminalExecute( deps: ToolDeps, args: { sessionId: string; command: string }, ): Promise> { const { bridge, context, commandBlocklist, permissionMode } = deps; const { sessionId, command } = args; if (!sessionId || !command) { return { ok: false, error: 'Missing sessionId or command' }; } const scopeErr = validateSessionScope(context, sessionId); if (scopeErr) return { ok: false, error: scopeErr }; if (isObserver(permissionMode)) { return { ok: false, error: 'Observer mode: command execution is disabled. Switch to Confirm or Auto mode to execute commands.' }; } // Shell blocklist is meaningless on network device CLIs (e.g. "shutdown" // disables an interface on Cisco). Skip for serial and network device sessions. // The bridge layer (handleExec / netcatty:ai:exec) also has its own session-aware check. const resolved = resolveContext(context); const targetSession = resolved.sessions.find(s => s.sessionId === sessionId); const proto = targetSession?.protocol || ''; const isSshOrSerial = proto === 'ssh' || proto === 'serial'; const isNetworkDevice = proto === 'serial' || (targetSession?.deviceType === 'network' && isSshOrSerial); if (!isNetworkDevice) { // Remote renderer metadata often has no shell type until the main/worker // process probes the live session. Pre-filter user/common rules here and // defer shell-specific defaults to that authoritative live check. const safety = targetSession?.shellType ? checkCommandSafety(command, commandBlocklist, targetSession.shellType) : checkCommandSafetyCommonOnly(command, commandBlocklist); if (safety.blocked) { return { ok: false, error: `Command blocked by safety policy. Matched pattern: ${safety.matchedPattern}` }; } } const result = await bridge.aiExec(sessionId, command, deps.chatSessionId); // Real execution failures (timeout, disconnect, no stream) have an `error` field if (!result.ok && result.error) { return { ok: false, error: result.error, data: { stdout: result.stdout || '', stderr: result.stderr || '', exitCode: isNetworkDevice ? (result.exitCode ?? null) : (result.exitCode ?? -1), }, }; } // Command ran (even if exit code is non-zero) — always return stdout+exitCode for LLM to judge. // Network device / serial sessions return exitCode: null because vendor CLIs don't expose // exit codes. Preserve null so the model knows exit status is unavailable rather than // seeing a misleading 0 (success) or -1 (failure). return { ok: true, data: { stdout: result.stdout || '', stderr: result.stderr || '', exitCode: isNetworkDevice ? (result.exitCode ?? null) : (result.exitCode ?? -1), }, }; } export function executeWorkspaceGetInfo( deps: ToolDeps, ): ToolExecResult<{ workspaceId: string | null; workspaceName: string | null; sessions: Array<{ sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; }>; }> { const context = resolveContext(deps.context); return { ok: true, data: { workspaceId: context.workspaceId || null, workspaceName: context.workspaceName || null, sessions: context.sessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, os: s.os, username: s.username, protocol: s.protocol, shellType: s.shellType, deviceType: s.deviceType, connected: s.connected, })), }, }; } export function executeWorkspaceGetSessionInfo( deps: ToolDeps, args: { sessionId: string }, ): ToolExecResult { const context = resolveContext(deps.context); const session = context.sessions.find(s => s.sessionId === args.sessionId); if (!session) { return { ok: false, error: `Session not found: ${args.sessionId}` }; } return { ok: true, data: session }; } // --------------------------------------------------------------------------- // Web Search & URL Fetch (read-only, no permission check needed) // --------------------------------------------------------------------------- export async function executeWebSearch( deps: ToolDeps, args: { query: string; maxResults?: number }, ): Promise }>> { const { bridge, webSearchConfig } = deps; if (!webSearchConfig?.enabled) { return { ok: false, error: 'Web search is not enabled. Please configure a search provider in Settings → AI.' }; } if (!args.query) { return { ok: false, error: 'Missing search query' }; } try { const maxResults = Math.max(1, Math.min(20, args.maxResults ?? webSearchConfig.maxResults ?? 5)); const results = await executeWebSearchProvider(bridge, webSearchConfig, args.query, maxResults); // Enforce maxResults after provider normalization (some providers ignore the limit) return { ok: true, data: { results: results.slice(0, maxResults) } }; } catch (err) { return { ok: false, error: `Web search failed: ${err instanceof Error ? err.message : String(err)}` }; } } interface BridgeFetchResponse { ok: boolean; status?: number; data?: string; error?: string; } export async function executeUrlFetch( deps: ToolDeps, args: { url: string; maxLength?: number }, ): Promise> { const { bridge } = deps; const { url } = args; if (!url || !url.startsWith('https://')) { return { ok: false, error: 'Invalid URL. Must start with https://' }; } const aiFetch = (bridge as unknown as Record Promise>).aiFetch; if (!aiFetch) { return { ok: false, error: 'aiFetch is not available on the bridge' }; } try { // skipHostCheck=true, followRedirects=true: url_fetch targets user-provided URLs const resp = await aiFetch(url, 'GET', { 'User-Agent': 'Netcatty-AI/1.0', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7', }, undefined, undefined, true, true) as BridgeFetchResponse; if (!resp.ok) { return { ok: false, error: resp.error || `HTTP ${resp.status}` }; } const maxLength = Math.max(1, Math.min(200000, args.maxLength ?? 50000)); let content = resp.data || ''; if (content.length > maxLength) { content = content.slice(0, maxLength) + '\n\n[Content truncated]'; } return { ok: true, data: { url, content, status: resp.status || 200 } }; } catch (err) { return { ok: false, error: `URL fetch failed: ${err instanceof Error ? err.message : String(err)}` }; } }