GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-27-p0-skill-secrets.md

504 lines
16 KiB
Markdown
Raw Permalink Normal View History

2026-05-20 21:39:12 +08:00
# 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"
```