GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-27-p1-skill-executor.md

685 lines
22 KiB
Markdown
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
# 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"
```