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

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

```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<Record<string, string>>;
}
```

修改 `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<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` 传入。

实现一个适配器：

```typescript
// 在 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 工具的位置（搜索 `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 布局**

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

```vue
<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 逻辑**

```typescript
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**

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