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

504 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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