# P1: Skill Runtime Executor 实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 新增 `execute_skill` Agent 工具和 `SkillExecutorService`,让 Agent 通过标准化接口调用 compute-entry skill,自动处理运行时选择、env 注入、子进程管理。同时新增 `skill.config.yaml` 解析器和依赖安装改造。 **Architecture:** 新增 `SkillConfigService` 解析 skill.config.yaml。`SkillExecutorService` 根据 config 的 runtime/entrypoint 声明 spawn 子进程,通过 stdin/stdout JSON 协议通信。`execute_skill` 工具在 `tool_resolver.ts` 中条件注入。依赖安装改造为 skill 级隔离(venv/node_modules)。 **Tech Stack:** Node.js child_process, js-yaml, TypeBox (参数校验), Midway.js DI **Spec:** `docs/superpowers/specs/2026-04-27-skill-system-evolution-design.md` Section 4 + 10.1 **Depends on:** P0 (SkillSecretService) --- ### Task 1: SkillConfigService — skill.config.yaml 解析器 **Files:** - Create: `packages/backend/src/modules/netaclaw/service/skill_config.ts` - Modify: `packages/shared/types/skill.types.ts` - [ ] **Step 1: 在 shared/types/skill.types.ts 新增 SkillConfig 类型** ```typescript export type SkillRuntime = 'node' | 'python' | 'bash' | 'dotnet'; export type SkillClassification = 'prompt' | 'compute-entry' | 'compute-toolkit'; export interface SkillSystemDep { name: string; check: string; } export interface SkillInterfaceField { type: string; required?: boolean; default?: string; description?: string; } export interface SkillRouteRule { match: string[]; required_refs: string[]; } export interface SkillConfig { runtime?: SkillRuntime; entrypoint?: string; timeout?: number; env?: Array<{ name: string; required: boolean; description?: string; default?: string }>; dependencies?: { system?: SkillSystemDep[]; python?: { source?: string; inline?: string[] }; node?: { packages?: string[] }; dotnet?: { project?: string }; }; setup?: { posix?: string; win32?: string }; interface?: { input?: Record; output?: Record; }; references?: { required?: string[]; optional?: string[]; routes?: SkillRouteRule[]; }; } ``` - [ ] **Step 2: 创建 skill_config.ts** ```typescript import { Provide, Scope, ScopeEnum, Logger } from '@midwayjs/core'; import { ILogger } from '@midwayjs/logger'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as yaml from 'js-yaml'; import type { SkillConfig, SkillClassification } from '../../../../shared/types/skill.types.js'; @Provide() @Scope(ScopeEnum.Singleton) export class SkillConfigService { @Logger() logger: ILogger; private configs: Map = new Map(); private parseErrors: Map = new Map(); async loadConfig(skillDir: string, skillName: string): Promise { const configPath = path.join(skillDir, 'skill.config.yaml'); try { const raw = await fs.readFile(configPath, 'utf-8'); const config = yaml.load(raw) as SkillConfig; this.configs.set(skillName, config); return config; } catch (e: any) { // 文件不存在 → 正常(prompt skill),解析失败 → 记录错误 if (e?.code !== 'ENOENT') { this.logger.warn('[SkillConfig] Failed to parse %s: %s', configPath, e.message); this.parseErrors.set(skillName, e.message); } this.configs.delete(skillName); return null; } } getParseError(skillName: string): string | null { return this.parseErrors.get(skillName) || null; } getConfig(skillName: string): SkillConfig | null { return this.configs.get(skillName) || null; } classify(skillName: string): SkillClassification { const config = this.configs.get(skillName); if (!config) return 'prompt'; if (config.entrypoint) return 'compute-entry'; if (config.runtime) return 'compute-toolkit'; return 'prompt'; } hasComputeEntrySkills(skillNames: string[]): boolean { return skillNames.some(name => this.classify(name) === 'compute-entry'); } clearAll(): void { this.configs.clear(); this.parseErrors.clear(); } } ``` - [ ] **Step 3: 验证编译通过** Run: `cd packages/backend && npx tsc --noEmit` - [ ] **Step 4: Commit** ```bash git add packages/shared/types/skill.types.ts packages/backend/src/modules/netaclaw/service/skill_config.ts git commit -m "feat(skill): add SkillConfigService for skill.config.yaml parsing" ``` --- ### Task 2: SkillLoaderService 集成 SkillConfigService **Files:** - Modify: `packages/backend/src/modules/netaclaw/service/skill_loader.ts` - [ ] **Step 1: 注入 SkillConfigService 并在 scanSkills 中加载 config** 在 class 顶部添加注入: ```typescript import { SkillConfigService } from './skill_config.js'; // class 内部 @Inject() skillConfig: SkillConfigService; ``` 在 `scanSkills()` 的循环中,加载完 SKILL.md 后尝试加载 skill.config.yaml: ```typescript // 在 this.skills.set(skill.name, skill) 之后添加 const skillDir = path.join(this.skillsDir, entry.name); await this.skillConfig.loadConfig(skillDir, skill.name); ``` 在 `scanSkills()` 开头的 `this.skills.clear()` 之后添加: ```typescript this.skillConfig.clearAll(); ``` - [ ] **Step 2: 新增 getSkillConfig 代理方法** ```typescript getSkillConfig(name: string): SkillConfig | null { return this.skillConfig.getConfig(name); } getSkillClassification(name: string): SkillClassification { return this.skillConfig.classify(name); } ``` - [ ] **Step 3: 验证编译通过** Run: `cd packages/backend && npx tsc --noEmit` - [ ] **Step 4: Commit** ```bash git add packages/backend/src/modules/netaclaw/service/skill_loader.ts git commit -m "feat(skill): integrate SkillConfigService into SkillLoaderService scan" ``` --- ### Task 3: SkillExecutorService 实现 **Files:** - Create: `packages/backend/src/modules/netaclaw/service/skill_executor.ts` - [ ] **Step 1: 创建 skill_executor.ts** ```typescript import { Provide, Inject, Scope, ScopeEnum, Logger } from '@midwayjs/core'; import { ILogger } from '@midwayjs/logger'; import { spawn } from 'child_process'; import * as path from 'path'; import { SkillLoaderService } from './skill_loader.js'; import { SkillSecretService } from './skill_secret.js'; import { SkillConfigService } from './skill_config.js'; const ENV_WHITELIST = [ 'PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'TZ', 'TEMP', 'TMP', 'TMPDIR', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'SystemRoot', 'APPDATA', 'LOCALAPPDATA', // Windows ]; export interface SkillExecuteParams { skillName: string; input: Record; agentId?: string; } export interface SkillExecuteResult { success: boolean; output?: Record; error?: string; duration: number; } @Provide() @Scope(ScopeEnum.Singleton) export class SkillExecutorService { @Logger() logger: ILogger; @Inject() skillLoader: SkillLoaderService; @Inject() skillSecret: SkillSecretService; @Inject() skillConfig: SkillConfigService; async execute(params: SkillExecuteParams): Promise { const startTime = Date.now(); const { skillName, input } = params; const skill = this.skillLoader.getSkill(skillName); if (!skill) { return { success: false, error: `Skill "${skillName}" not found`, duration: 0 }; } const config = this.skillConfig.getConfig(skillName); if (!config?.entrypoint) { return { success: false, error: `Skill "${skillName}" is not a compute-entry skill`, duration: 0 }; } const skillDir = path.join(this.skillLoader.getSkillsDir(), skillName); const timeout = config.timeout || 30000; // 安全校验:entrypoint 路径穿越防护 const resolvedEntry = path.resolve(skillDir, config.entrypoint); if (!resolvedEntry.startsWith(path.resolve(skillDir) + path.sep)) { return { success: false, error: 'Entrypoint path traversal detected', duration: 0 }; } // 预检:Python runtime 检查 .venv 是否存在 if ((config.runtime || 'python') === 'python') { const venvPath = path.join(skillDir, '.venv'); try { const fs = await import('fs/promises'); await fs.access(venvPath); } catch { return { success: false, error: `Python venv not found at ${venvPath}. Run dependency install first (POST /admin/netaclaw/skill/installDeps).`, duration: 0 }; } } // Build command based on runtime const { cmd, args } = this.buildCommand(config.runtime || 'python', config.entrypoint, skillDir); // Build env: whitelist + skill-scoped secrets const baseEnv: Record = {}; for (const key of ENV_WHITELIST) { if (process.env[key]) baseEnv[key] = process.env[key]!; } const skillEnv = await this.skillSecret.resolveEnv(skillName); const env = { ...baseEnv, ...skillEnv }; return new Promise((resolve) => { let stdout = ''; let stderr = ''; let timedOut = false; const child = spawn(cmd, args, { cwd: skillDir, env, stdio: ['pipe', 'pipe', 'pipe'], timeout: 0, // we handle timeout manually }); const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, timeout); child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); // Send input via stdin child.stdin.write(JSON.stringify(input)); child.stdin.end(); child.on('close', (code) => { clearTimeout(timer); const duration = Date.now() - startTime; this.logger.info('[SkillExecutor] %s executed agent=%s duration=%dms exit=%d', skillName, params.agentId || 'unknown', duration, code); if (timedOut) { resolve({ success: false, error: `Execution timed out after ${timeout}ms`, duration }); return; } if (code !== 0) { const errMsg = stderr.slice(0, 500) || `Process exited with code ${code}`; resolve({ success: false, error: errMsg, duration }); return; } try { const output = JSON.parse(stdout.trim()); resolve({ success: true, output, duration }); } catch { resolve({ success: false, error: `Invalid JSON output: ${stdout.slice(0, 200)}`, duration }); } }); child.on('error', (err) => { clearTimeout(timer); resolve({ success: false, error: err.message, duration: Date.now() - startTime }); }); }); } private buildCommand(runtime: string, entrypoint: string, skillDir: string): { cmd: string; args: string[] } { const isWin = process.platform === 'win32'; switch (runtime) { case 'python': { const python = isWin ? path.join(skillDir, '.venv', 'Scripts', 'python.exe') : path.join(skillDir, '.venv', 'bin', 'python'); return { cmd: python, args: [entrypoint] }; } case 'node': return { cmd: 'node', args: [entrypoint] }; case 'bash': return { cmd: isWin ? 'bash' : '/bin/bash', args: [entrypoint] }; case 'dotnet': return { cmd: 'dotnet', args: ['run', '--project', entrypoint] }; default: return { cmd: runtime, args: [entrypoint] }; } } } ``` - [ ] **Step 2: 验证编译通过** Run: `cd packages/backend && npx tsc --noEmit` - [ ] **Step 3: Commit** ```bash git add packages/backend/src/modules/netaclaw/service/skill_executor.ts git commit -m "feat(skill): add SkillExecutorService for compute-entry skill execution" ``` --- ### Task 4: execute_skill Agent 工具 **Files:** - Create: `packages/backend/src/modules/netaclaw/tools/builtin/execute_skill.ts` - Modify: `packages/backend/src/modules/netaclaw/tools/catalog.ts` - Modify: `packages/backend/src/modules/netaclaw/service/tool_resolver.ts` - [ ] **Step 1: 创建 execute_skill.ts** ```typescript import { Type } from '@sinclair/typebox'; import { AgentToolWithMeta, textResult } from '../common.js'; import { SkillExecutorService } from '../../service/skill_executor.js'; const ExecuteSkillParams = Type.Object({ name: Type.String({ description: 'compute-entry skill 名称' }), input: Type.Record(Type.String(), Type.Unknown(), { description: '输入参数 JSON' }), }); export function createExecuteSkillTool(executor: SkillExecutorService): AgentToolWithMeta { return { name: 'execute_skill', label: '执行 Skill', description: '执行 compute-entry 类型的 skill。传入 skill 名称和输入参数,返回执行结果。', parameters: ExecuteSkillParams, async execute(_id, params) { const result = await executor.execute({ skillName: params.name, input: params.input as Record, }); if (result.success) { return textResult(JSON.stringify(result.output, null, 2)); } else { return textResult(`执行失败: ${result.error}`); } }, }; } import { registerSchema } from '../catalog.js'; registerSchema({ name: 'execute_skill', toolset: 'skill', description: '执行 compute-entry 类型的技能', capability: 'text', visibility: 'skill', }); ``` - [ ] **Step 2: 在 catalog.ts 中导入 execute_skill** 在 catalog.ts 的 import 区域(约 line 49-65)添加: ```typescript import '../tools/builtin/execute_skill.js'; ``` - [ ] **Step 3: 在 tool_resolver.ts 中注入并条件添加 execute_skill** 在 import 区域添加: ```typescript import { createExecuteSkillTool } from '../tools/builtin/execute_skill.js'; import { SkillExecutorService } from './skill_executor.js'; ``` 在 class 内部添加注入: ```typescript @Inject() skillExecutor: SkillExecutorService; ``` 在 `resolve()` 方法中,现有 skill 工具注入块(约 line 602-606)之后添加: ```typescript // 仅当 Agent 配置的 skills 中存在 compute-entry 类型时才注入 if (filteredNames.includes('execute_skill')) { const agentSkills = params.agent?.skills || []; const hasComputeEntry = agentSkills.some(name => this.skillConfig.classify(name) === 'compute-entry'); if (hasComputeEntry) { runtimeTools.push(createExecuteSkillTool(this.skillExecutor)); } } ``` - [ ] **Step 4: 验证编译通过** Run: `cd packages/backend && npx tsc --noEmit` - [ ] **Step 5: Commit** ```bash git add packages/backend/src/modules/netaclaw/tools/builtin/execute_skill.ts \ packages/backend/src/modules/netaclaw/tools/catalog.ts \ packages/backend/src/modules/netaclaw/service/tool_resolver.ts git commit -m "feat(skill): add execute_skill tool with conditional injection in tool_resolver" ``` --- ### Task 5: 依赖安装改造 **Files:** - Modify: `packages/backend/src/modules/netaclaw/service/skill_installer.ts` - [ ] **Step 1: 重构 installDependencies 方法** 替换现有 `installDependencies` 方法体,改为 skill 级隔离安装: ```typescript async installDependencies( skillName: string, installSpecs?: Record[], ): Promise<{ success: boolean; logs: string[] }> { const logs: string[] = []; const skillDir = path.join(this.skillsDir, skillName); // 读取 skill.config.yaml 的 dependencies const configPath = path.join(skillDir, 'skill.config.yaml'); let config: any = null; try { const raw = await fs.readFile(configPath, 'utf-8'); const yaml = await import('js-yaml'); config = yaml.load(raw); } catch { /* no config file */ } const deps = config?.dependencies; // Python: skill-level venv if (deps?.python) { try { const reqSource = deps.python.source || 'requirements.txt'; const reqPath = path.join(skillDir, reqSource); await fs.access(reqPath); await execAsync(`uv venv .venv`, { cwd: skillDir, timeout: 60000 }); const pip = process.platform === 'win32' ? path.join('.venv', 'Scripts', 'pip') : path.join('.venv', 'bin', 'pip'); const { stdout } = await execAsync(`uv pip install -r ${reqSource} -p ${pip}`, { cwd: skillDir, timeout: 120000, }); logs.push(`[python] venv created and deps installed: ${stdout.trim()}`); } catch (e: any) { logs.push(`[python] install failed: ${e.message}`); } } // Node: skill-level node_modules if (deps?.node?.packages?.length) { try { const pkgs = deps.node.packages.join(' '); const { stdout } = await execAsync(`npm install ${pkgs}`, { cwd: skillDir, timeout: 120000, }); logs.push(`[node] installed: ${stdout.trim()}`); } catch (e: any) { logs.push(`[node] install failed: ${e.message}`); } } // Dotnet: restore if (deps?.dotnet?.project) { try { const { stdout } = await execAsync(`dotnet restore ${deps.dotnet.project}`, { cwd: skillDir, timeout: 120000, }); logs.push(`[dotnet] restored: ${stdout.trim()}`); } catch (e: any) { logs.push(`[dotnet] restore failed: ${e.message}`); } } // System deps: check only if (deps?.system) { for (const dep of deps.system) { try { await execAsync(dep.check, { timeout: 10000 }); logs.push(`[system] ${dep.name}: OK`); } catch { logs.push(`[system] ${dep.name}: NOT FOUND (run "${dep.check}" to verify)`); } } } // Setup script — 安全约束(Spec 10.9) const setupKey = process.platform === 'win32' ? 'win32' : 'posix'; const setupScript = config?.setup?.[setupKey]; if (setupScript) { // 路径穿越防护 if (setupScript.includes('..')) { logs.push(`[setup] 跳过: 路径包含 ".." (${setupScript})`); } else { // 来源校验:仅 github/zip 安装的 skill 允许执行 setup const origin = await this.registry.readOrigin(skillName); const allowedSources = ['github', 'zip']; if (!origin || !allowedSources.includes(origin.source)) { logs.push(`[setup] 跳过: 仅 GitHub/ZIP 安装的 skill 允许执行 setup 脚本 (source=${origin?.source || 'local'})`); } else { try { const { stdout } = await execAsync( process.platform === 'win32' ? `powershell -File ${setupScript}` : `bash ${setupScript}`, { cwd: skillDir, timeout: 120000 }, ); logs.push(`[setup] executed ${setupScript}: ${stdout.trim()}`); } catch (e: any) { logs.push(`[setup] ${setupScript} failed: ${e.message}`); } } } } // Fallback: legacy installSpecs from SKILL.md metadata if (!config && installSpecs && Array.isArray(installSpecs)) { for (const spec of installSpecs) { const kind = spec.kind as string; const pkg = spec.package as string; if (!kind || !pkg) continue; try { if (kind === 'node' && SAFE_NODE_PACKAGE.test(pkg)) { const { stdout } = await execAsync(`npm install ${pkg}`, { cwd: skillDir, timeout: 120000 }); logs.push(`[node-legacy] installed ${pkg}: ${stdout.trim()}`); } else if (kind === 'uv' && SAFE_UV_PACKAGE.test(pkg)) { await execAsync(`uv venv .venv`, { cwd: skillDir, timeout: 60000 }); const { stdout } = await execAsync(`uv pip install ${pkg}`, { cwd: skillDir, timeout: 120000 }); logs.push(`[uv-legacy] installed ${pkg}: ${stdout.trim()}`); } } catch (e: any) { logs.push(`[${kind}] install ${pkg} failed: ${e.message}`); } } } return { success: logs.every(l => !l.includes('failed')), logs }; } ``` - [ ] **Step 2: 验证编译通过** Run: `cd packages/backend && npx tsc --noEmit` - [ ] **Step 3: Commit** ```bash git add packages/backend/src/modules/netaclaw/service/skill_installer.ts git commit -m "feat(skill): refactor dependency installation to skill-level isolation" ``` --- ### Task 6: 前端安装对话框 setup 脚本确认(Spec 10.9) **Files:** - Modify: `packages/frontend/src/modules/agent/views/skills.vue` - [ ] **Step 1: 安装完成后检查是否有 setup 脚本并弹出确认** 修改 `handleInstallFromGitHub` 和 `handleInstallFromZip` 函数,安装成功后检查返回的 meta 是否包含 setup 脚本: ```typescript async function handleInstallFromGitHub() { // ... 现有安装逻辑 ... const meta = res; // 安装返回的 skillMeta installDialogVisible.value = false; await refresh(); // 检查是否有 setup 脚本需要执行 if (meta?.metadata?.setup) { const setupKey = navigator.platform.startsWith('Win') ? 'win32' : 'posix'; const scriptName = meta.metadata.setup[setupKey]; if (scriptName) { await ElMessageBox.confirm( `此 Skill 包含安装脚本(${scriptName}),是否执行?执行将安装 Skill 所需的系统依赖。`, '安装脚本确认', { confirmButtonText: '执行', cancelButtonText: '跳过', type: 'warning' }, ); // 用户确认后执行依赖安装(包含 setup 脚本) try { await service.request({ url: '/admin/netaclaw/skill/installDeps', method: 'POST', data: { name: meta.name }, }); ElMessage.success('安装脚本执行完成'); } catch (e: any) { ElMessage.error(e.message || '安装脚本执行失败'); } } } } ``` 对 `handleInstallFromZip` 做同样的改造。 - [ ] **Step 2: Commit** ```bash git add packages/frontend/src/modules/agent/views/skills.vue git commit -m "feat(skill): add setup script confirmation dialog after skill installation" ```