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

16 KiB
Raw Permalink Blame History

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(加密 TEXTenvSchemaJSON 声明字段。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 字段

// 在 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 类型
export interface EnvSchemaItem {
  name: string;
  required: boolean;
  description?: string;
  default?: string;
}
  • Step 3: 重启开发服务器验证 TypeORM 自动同步

Run: 重启 backend检查 netaclaw_skill 表是否新增了 secretsenvSchema 列。可通过 MCP mysql describe_table 工具验证。

  • Step 4: Commit
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 文件,实现加密/解密

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<NetaClawSkillEntity>;

  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, string>): 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<string, string> {
    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<Record<string, string>> {
    const entity = await this.skillRepo.findOneBy({ name: skillName });
    if (!entity) return {};

    const env: Record<string, string> = {};

    // 填充 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<string, string>): Promise<void> {
    const encrypted = this.encrypt(secrets);
    await this.skillRepo.update({ name: skillName }, { secrets: encrypted });
  }

  async getConfiguredKeys(skillName: string): Promise<Array<{ name: string; hasValue: boolean }>> {
    const entity = await this.skillRepo.findOneBy({ name: skillName });
    if (!entity?.envSchema) return [];

    let configuredKeys = new Set<string>();
    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
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 区域添加:

import { SkillSecretService } from '../../service/skill_secret.js';

在 class 内部添加注入:

@Inject()
skillSecret: SkillSecretService;
  • Step 2: 新增 envSchema 端点
@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 保存端点
@Post('/secrets', { summary: '保存 skill secrets加密存储' })
async saveSecrets(@Body() body: { name: string; secrets: Record<string, string> }) {
  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
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 方法之后)添加:

/** 根据绝对路径判断是否属于某个 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
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 接口之前添加:

export interface BashEnvProvider {
  getAdditionalEnv(cwd: string): Promise<Record<string, string>>;
}

修改 createLocalBashOperations 签名:

export function createLocalBashOperations(envProvider?: BashEnvProvider): BashOperations {
  • Step 2: 在 exec 方法中注入额外 env

修改 exec 方法内部,在 spawn 之前获取额外 env

exec: async (command, cwd, options) => {
  const shellConfig = resolveShellConfig();
  const shell = shellConfig.shell;
  const shellArgs = [...shellConfig.args, command];

  // 获取 skill-scoped env
  let envVars: Record<string, string> = { ...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 传入。

实现一个适配器:

// 在 bash.ts 底部或单独文件
export function createSkillBashEnvProvider(
  skillLoader: SkillLoaderService,
  skillSecret: SkillSecretService,
): BashEnvProvider {
  return {
    async getAdditionalEnv(cwd: string): Promise<Record<string, string>> {
      const skillName = skillLoader.resolveSkillByPath(cwd);
      if (!skillName) return {};
      return skillSecret.resolveEnv(skillName);
    },
  };
}
  • Step 3.5: 在 tool_resolver.ts 中接线 BashEnvProvider

找到 tool_resolver.ts 中创建 bash 工具的位置(搜索 createBashToolcreateLocalBashOperations)。将 SkillLoaderServiceSkillSecretService 组合为 BashEnvProvider 传入:

// 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 布局

将现有内容包裹在 <el-tabs> 中,第一个 tab 保留原有内容(基本信息 + SKILL.md新增第二个 tab "配置"

<el-tabs v-model="activeTab">
  <el-tab-pane label="基本信息" name="info">
    <!-- 现有内容移入此处 -->
  </el-tab-pane>
  <el-tab-pane label="配置" name="config">
    <div v-if="envSchema.length === 0" class="empty-hint"> Skill 无需配置环境变量</div>
    <div v-else>
      <div v-for="item in envSchema" :key="item.name" class="env-row">
        <div class="env-label">
          <span>{{ item.name }}</span>
          <el-tag v-if="item.required" size="small" type="danger">必填</el-tag>
        </div>
        <div v-if="item.description" class="env-desc">{{ item.description }}</div>
        <el-input
          v-model="secretValues[item.name]"
          :placeholder="getConfiguredStatus(item.name) ? '已配置(留空则不修改)' : item.default || '请输入'"
          show-password
        />
      </div>
      <el-button type="primary" style="margin-top: 16px" @click="handleSaveSecrets">保存配置</el-button>
    </div>
  </el-tab-pane>
</el-tabs>
  • Step 2: 新增配置相关的 script 逻辑
const activeTab = ref('info');
const envSchema = ref<any[]>([]);
const configuredKeys = ref<any[]>([]);
const secretValues = ref<Record<string, string>>({});

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

// 在 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
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"