GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-27-p1-skill-executor.md
2026-05-20 21:39:12 +08:00

22 KiB
Raw Blame History

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 类型

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<string, SkillInterfaceField>;
    output?: Record<string, SkillInterfaceField>;
  };
  references?: {
    required?: string[];
    optional?: string[];
    routes?: SkillRouteRule[];
  };
}
  • Step 2: 创建 skill_config.ts
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<string, SkillConfig> = new Map();
  private parseErrors: Map<string, string> = new Map();

  async loadConfig(skillDir: string, skillName: string): Promise<SkillConfig | null> {
    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
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 顶部添加注入:

import { SkillConfigService } from './skill_config.js';

// class 内部
@Inject()
skillConfig: SkillConfigService;

scanSkills() 的循环中,加载完 SKILL.md 后尝试加载 skill.config.yaml

// 在 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() 之后添加:

this.skillConfig.clearAll();
  • Step 2: 新增 getSkillConfig 代理方法
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
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

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<string, unknown>;
  agentId?: string;
}

export interface SkillExecuteResult {
  success: boolean;
  output?: Record<string, unknown>;
  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<SkillExecuteResult> {
    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<string, string> = {};
    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<SkillExecuteResult>((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
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

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<typeof ExecuteSkillParams, unknown> {
  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<string, unknown>,
      });
      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添加

import '../tools/builtin/execute_skill.js';
  • Step 3: 在 tool_resolver.ts 中注入并条件添加 execute_skill

在 import 区域添加:

import { createExecuteSkillTool } from '../tools/builtin/execute_skill.js';
import { SkillExecutorService } from './skill_executor.js';

在 class 内部添加注入:

@Inject()
skillExecutor: SkillExecutorService;

resolve() 方法中,现有 skill 工具注入块(约 line 602-606之后添加

// 仅当 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
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 级隔离安装:

async installDependencies(
  skillName: string,
  installSpecs?: Record<string, unknown>[],
): 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
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 脚本并弹出确认

修改 handleInstallFromGitHubhandleInstallFromZip 函数,安装成功后检查返回的 meta 是否包含 setup 脚本:

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
git add packages/frontend/src/modules/agent/views/skills.vue
git commit -m "feat(skill): add setup script confirmation dialog after skill installation"