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

685 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<string, SkillInterfaceField>;
output?: Record<string, SkillInterfaceField>;
};
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<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**
```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<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**
```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<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添加
```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<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**
```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"
```