685 lines
22 KiB
Markdown
685 lines
22 KiB
Markdown
|
|
# 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"
|
|||
|
|
```
|