# P0: Skill-Scoped 密钥管理 实施计划 > **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:** 让每个 Skill 拥有独立的密钥/配置管理,通过 DB 加密存储,前端可视化配置,运行时自动注入到 skill 子进程环境变量。 **Architecture:** 新增 `SkillSecretService` 负责 AES-256-GCM 加密/解密。`netaclaw_skill` 表新增 `secrets`(加密 TEXT)和 `envSchema`(JSON 声明)字段。Admin controller 暴露配置端点,前端 skill-detail 抽屉新增配置 tab。bash 工具重构支持 env override,运行 skill 目录下脚本时自动注入 skill-scoped env。 **Tech Stack:** Node.js crypto (AES-256-GCM), TypeORM, Midway.js DI, Element Plus (前端) **Spec:** `docs/superpowers/specs/2026-04-27-skill-system-evolution-design.md` Section 3 + 10.2-10.4 --- ### Task 1: Entity 字段扩展 **Files:** - Modify: `packages/backend/src/modules/netaclaw/entity/skill.ts` - Modify: `packages/shared/types/skill.types.ts` - [ ] **Step 1: 在 entity/skill.ts 新增 secrets 和 envSchema 字段** ```typescript // 在 fingerprint 字段之后添加 @Column({ type: 'text', comment: 'AES-256-GCM 加密的 secrets JSON', nullable: true }) secrets: string; @Column({ type: 'json', comment: 'env 声明 schema', nullable: true }) envSchema: Array<{ name: string; required: boolean; description?: string; default?: string }>; ``` - [ ] **Step 2: 在 shared/types/skill.types.ts 新增 EnvSchemaItem 类型** ```typescript export interface EnvSchemaItem { name: string; required: boolean; description?: string; default?: string; } ``` - [ ] **Step 3: 重启开发服务器验证 TypeORM 自动同步** Run: 重启 backend,检查 `netaclaw_skill` 表是否新增了 `secrets` 和 `envSchema` 列。可通过 MCP mysql `describe_table` 工具验证。 - [ ] **Step 4: Commit** ```bash git add packages/backend/src/modules/netaclaw/entity/skill.ts packages/shared/types/skill.types.ts git commit -m "feat(skill): add secrets and envSchema columns to netaclaw_skill entity" ``` --- ### Task 2: SkillSecretService 实现 **Files:** - Create: `packages/backend/src/modules/netaclaw/service/skill_secret.ts` - [ ] **Step 1: 创建 skill_secret.ts 文件,实现加密/解密** ```typescript import { Provide, Scope, ScopeEnum, Logger, Init } from '@midwayjs/core'; import { ILogger } from '@midwayjs/logger'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository } from 'typeorm'; import * as crypto from 'crypto'; import { NetaClawSkillEntity } from '../entity/skill.js'; @Provide() @Scope(ScopeEnum.Singleton) export class SkillSecretService { @Logger() logger: ILogger; @InjectEntityModel(NetaClawSkillEntity) skillRepo: Repository; private readonly algorithm = 'aes-256-gcm' as const; private readonly ivLength = 16; private readonly authTagLength = 16; private deriveKey(): Buffer { const raw = process.env.SKILL_SECRET_KEY || process.env.APP_SECRET; if (!raw) throw new Error('SKILL_SECRET_KEY or APP_SECRET environment variable must be set'); return crypto.createHash('sha256').update(raw).digest(); } encrypt(plainObj: Record): string { const iv = crypto.randomBytes(this.ivLength); const key = this.deriveKey(); const cipher = crypto.createCipheriv(this.algorithm, key, iv); const encrypted = Buffer.concat([ cipher.update(JSON.stringify(plainObj), 'utf8'), cipher.final(), ]); const authTag = cipher.getAuthTag(); return Buffer.concat([iv, encrypted, authTag]).toString('base64'); } decrypt(cipherText: string): Record { const buf = Buffer.from(cipherText, 'base64'); const iv = buf.subarray(0, this.ivLength); const authTag = buf.subarray(buf.length - this.authTagLength); const encrypted = buf.subarray(this.ivLength, buf.length - this.authTagLength); const key = this.deriveKey(); const decipher = crypto.createDecipheriv(this.algorithm, key, iv); decipher.setAuthTag(authTag); const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); return JSON.parse(decrypted.toString('utf8')); } async resolveEnv(skillName: string): Promise> { const entity = await this.skillRepo.findOneBy({ name: skillName }); if (!entity) return {}; const env: Record = {}; // 填充 defaults if (entity.envSchema) { for (const item of entity.envSchema) { if (item.default) env[item.name] = item.default; } } // 覆盖 secrets if (entity.secrets) { try { const secrets = this.decrypt(entity.secrets); Object.assign(env, secrets); } catch (e) { this.logger.error('[SkillSecret] 解密 %s secrets 失败: %s', skillName, e); } } return env; } async saveSecrets(skillName: string, secrets: Record): Promise { const encrypted = this.encrypt(secrets); await this.skillRepo.update({ name: skillName }, { secrets: encrypted }); } async getConfiguredKeys(skillName: string): Promise> { const entity = await this.skillRepo.findOneBy({ name: skillName }); if (!entity?.envSchema) return []; let configuredKeys = new Set(); if (entity.secrets) { try { const secrets = this.decrypt(entity.secrets); configuredKeys = new Set(Object.keys(secrets)); } catch { /* ignore */ } } return entity.envSchema.map(item => ({ name: item.name, hasValue: configuredKeys.has(item.name), })); } } ``` - [ ] **Step 2: 验证编译通过** Run: `cd packages/backend && npx tsc --noEmit` Expected: 无错误 - [ ] **Step 3: Commit** ```bash git add packages/backend/src/modules/netaclaw/service/skill_secret.ts git commit -m "feat(skill): add SkillSecretService with AES-256-GCM encryption" ``` --- ### Task 3: Admin Controller 端点 **Files:** - Modify: `packages/backend/src/modules/netaclaw/controller/admin/skill.ts` - [ ] **Step 1: 在 AdminNetaClawSkillController 中注入 SkillSecretService** 在文件顶部 import 区域添加: ```typescript import { SkillSecretService } from '../../service/skill_secret.js'; ``` 在 class 内部添加注入: ```typescript @Inject() skillSecret: SkillSecretService; ``` - [ ] **Step 2: 新增 envSchema 端点** ```typescript @Get('/envSchema', { summary: '获取 skill 的 env 声明和配置状态' }) async envSchema(@Query('name') name: string) { if (!name) return this.fail('缺少 name 参数'); const keys = await this.skillSecret.getConfiguredKeys(name); const skill = await this.skillLoader.getSkill(name); const schema = skill?.metadata?.env || []; return this.ok({ name, schema, configured: keys }); } ``` - [ ] **Step 3: 新增 secrets 保存端点** ```typescript @Post('/secrets', { summary: '保存 skill secrets(加密存储)' }) async saveSecrets(@Body() body: { name: string; secrets: Record }) { if (!body.name || !body.secrets) return this.fail('缺少 name 或 secrets'); try { await this.skillSecret.saveSecrets(body.name, body.secrets); return this.ok(); } catch (e: any) { return this.fail(e.message); } } ``` - [ ] **Step 4: 验证编译通过** Run: `cd packages/backend && npx tsc --noEmit` - [ ] **Step 5: Commit** ```bash git add packages/backend/src/modules/netaclaw/controller/admin/skill.ts git commit -m "feat(skill): add envSchema and secrets admin endpoints" ``` --- ### Task 4: SkillLoaderService 新增 resolveSkillByPath **Files:** - Modify: `packages/backend/src/modules/netaclaw/service/skill_loader.ts` - [ ] **Step 1: 在 SkillLoaderService 中新增 resolveSkillByPath 方法** 在 class 末尾(`getSkillFilePath` 方法之后)添加: ```typescript /** 根据绝对路径判断是否属于某个 skill 目录,返回 skill name 或 null */ resolveSkillByPath(absPath: string): string | null { if (!absPath) return null; const normalized = path.resolve(absPath); const skillsDirNorm = path.resolve(this.skillsDir); if (!normalized.startsWith(skillsDirNorm + path.sep)) return null; const relative = normalized.slice(skillsDirNorm.length + 1); const skillName = relative.split(path.sep)[0]; if (!skillName || !this.skills.has(skillName)) return null; return skillName; } ``` - [ ] **Step 2: 验证编译通过** Run: `cd packages/backend && npx tsc --noEmit` - [ ] **Step 3: Commit** ```bash git add packages/backend/src/modules/netaclaw/service/skill_loader.ts git commit -m "feat(skill): add resolveSkillByPath for skill directory detection" ``` --- ### Task 5: Bash 工具 env 注入重构 **Files:** - Modify: `packages/backend/src/modules/netaclaw/tools/builtin/bash.ts` - [ ] **Step 1: 新增 BashEnvProvider 接口并修改 createLocalBashOperations 签名** 在 `BashToolOptions` 接口之前添加: ```typescript export interface BashEnvProvider { getAdditionalEnv(cwd: string): Promise>; } ``` 修改 `createLocalBashOperations` 签名: ```typescript export function createLocalBashOperations(envProvider?: BashEnvProvider): BashOperations { ``` - [ ] **Step 2: 在 exec 方法中注入额外 env** 修改 `exec` 方法内部,在 spawn 之前获取额外 env: ```typescript exec: async (command, cwd, options) => { const shellConfig = resolveShellConfig(); const shell = shellConfig.shell; const shellArgs = [...shellConfig.args, command]; // 获取 skill-scoped env let envVars: Record = { ...process.env } as any; if (envProvider) { try { const additional = await envProvider.getAdditionalEnv(cwd); Object.assign(envVars, additional); } catch { /* ignore env resolution failures */ } } return new Promise(async (resolve, reject) => { const child = await spawnWithFallback(shell, shellArgs, { cwd, env: envVars, stdio: ['ignore', 'pipe', 'pipe'], ...withHiddenWindow({}), }).catch(reject); // ... rest unchanged ``` - [ ] **Step 3: 找到 createLocalBashOperations 的调用点,传入 envProvider** 搜索 `createLocalBashOperations()` 的调用位置(通常在 bash 工具的工厂函数或 tool_resolver 中),将 `SkillSecretService` + `SkillLoaderService` 组合为 `BashEnvProvider` 传入。 实现一个适配器: ```typescript // 在 bash.ts 底部或单独文件 export function createSkillBashEnvProvider( skillLoader: SkillLoaderService, skillSecret: SkillSecretService, ): BashEnvProvider { return { async getAdditionalEnv(cwd: string): Promise> { const skillName = skillLoader.resolveSkillByPath(cwd); if (!skillName) return {}; return skillSecret.resolveEnv(skillName); }, }; } ``` - [ ] **Step 3.5: 在 tool_resolver.ts 中接线 BashEnvProvider** 找到 `tool_resolver.ts` 中创建 bash 工具的位置(搜索 `createBashTool` 或 `createLocalBashOperations`)。将 `SkillLoaderService` 和 `SkillSecretService` 组合为 `BashEnvProvider` 传入: ```typescript // tool_resolver.ts 中 import { createSkillBashEnvProvider } from '../tools/builtin/bash.js'; import { SkillSecretService } from './skill_secret.js'; // class 内部注入 @Inject() skillSecret: SkillSecretService; // 在 bash 工具创建处传入 envProvider const bashEnvProvider = createSkillBashEnvProvider(this.skillLoader, this.skillSecret); ``` 具体接线方式取决于 bash 工具的创建路径——在实施时需要 trace `createLocalBashOperations()` 的调用链,将 `bashEnvProvider` 参数传递到位。 - [ ] **Step 4: 验证编译通过(bash.ts)** --- ### Task 6: 前端 skill-detail 配置 Tab **Files:** - Modify: `packages/frontend/src/modules/agent/components/skill-detail.vue` - [ ] **Step 1: 重构 skill-detail.vue 为 tab 布局** 将现有内容包裹在 `` 中,第一个 tab 保留原有内容(基本信息 + SKILL.md),新增第二个 tab "配置": ```vue
此 Skill 无需配置环境变量
{{ item.name }} 必填
{{ item.description }}
保存配置
``` - [ ] **Step 2: 新增配置相关的 script 逻辑** ```typescript const activeTab = ref('info'); const envSchema = ref([]); const configuredKeys = ref([]); const secretValues = ref>({}); function getConfiguredStatus(name: string): boolean { return configuredKeys.value.find(k => k.name === name)?.hasValue || false; } async function loadEnvSchema() { if (!props.skill?.name) return; try { const res = await service.request({ url: '/admin/netaclaw/skill/envSchema', params: { name: props.skill.name }, }); envSchema.value = res?.schema || []; configuredKeys.value = res?.configured || []; } catch { /* ignore */ } } async function handleSaveSecrets() { const nonEmpty = Object.fromEntries( Object.entries(secretValues.value).filter(([_, v]) => v.trim()) ); if (Object.keys(nonEmpty).length === 0) { ElMessage.warning('请至少填写一个配置项'); return; } try { await service.request({ url: '/admin/netaclaw/skill/secrets', method: 'POST', data: { name: props.skill.name, secrets: nonEmpty }, }); ElMessage.success('配置已保存'); secretValues.value = {}; await loadEnvSchema(); } catch (e: any) { ElMessage.error(e.message || '保存失败'); } } watch(() => props.skill, () => { activeTab.value = 'info'; secretValues.value = {}; loadEnvSchema(); }); ``` - [ ] **Step 3: 验证前端编译通过** Run: `cd packages/frontend && npx vue-tsc --noEmit` - [ ] **Step 4: Commit** ```bash git add packages/frontend/src/modules/agent/components/skill-detail.vue git commit -m "feat(skill): add configuration tab to skill-detail drawer" ``` --- ### Task 7: SkillLoaderService 解析 envSchema 并同步到 DB **Files:** - Modify: `packages/backend/src/modules/netaclaw/service/skill_loader.ts` - [ ] **Step 1: 在 scanSkills 中解析 metadata.env 并同步 envSchema 到 DB** 在 `getSkillMetas()` 方法中,当同步到 DB 时,同时写入 envSchema: ```typescript // 在 getSkillMetas() 的 skillRepo.save 调用中,添加 envSchema 字段 const envFromMeta = (fs.metadata as any)?.env; const envSchema = Array.isArray(envFromMeta) ? envFromMeta.map((e: any) => ({ name: e.name, required: !!e.required, description: e.description || undefined, default: e.default || undefined, })) : null; // 在 save/update 的 entityData 中加入 envSchema, ``` - [ ] **Step 2: 验证编译通过** Run: `cd packages/backend && npx tsc --noEmit` - [ ] **Step 3: Commit** ```bash git add packages/backend/src/modules/netaclaw/service/skill_loader.ts git commit -m "feat(skill): sync envSchema from SKILL.md metadata to DB during scan" ```