Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions backend/src/routes/__tests__/skillAdminRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 Gracker (Chris)
// This file is part of SmartPerfetto. See LICENSE for details.

import express from 'express';
import request from 'supertest';

import { ENTERPRISE_FEATURE_FLAG_ENV } from '../../config';
import skillAdminRoutes from '../skillAdminRoutes';

const originalEnv = {
enterprise: process.env[ENTERPRISE_FEATURE_FLAG_ENV],
trustedHeaders: process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS,
apiKey: process.env.SMARTPERFETTO_API_KEY,
};

function restoreEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}

function makeApp(): express.Express {
const app = express();
app.use(express.json());
app.use('/api/admin', skillAdminRoutes);
return app;
}

function adminHeaders(req: request.Test): request.Test {
return req
.set('X-SmartPerfetto-SSO-User-Id', 'admin-a')
.set('X-SmartPerfetto-SSO-Email', 'admin-a@example.test')
.set('X-SmartPerfetto-SSO-Tenant-Id', 'tenant-a')
.set('X-SmartPerfetto-SSO-Workspace-Id', 'workspace-a')
.set('X-SmartPerfetto-SSO-Roles', 'org_admin')
.set('X-SmartPerfetto-SSO-Scopes', '*');
}

describe('skill admin enterprise guard', () => {
let app: express.Express;

beforeEach(() => {
process.env[ENTERPRISE_FEATURE_FLAG_ENV] = 'true';
process.env.SMARTPERFETTO_SSO_TRUSTED_HEADERS = 'true';
delete process.env.SMARTPERFETTO_API_KEY;
app = makeApp();
});

afterEach(() => {
restoreEnvValue(ENTERPRISE_FEATURE_FLAG_ENV, originalEnv.enterprise);
restoreEnvValue('SMARTPERFETTO_SSO_TRUSTED_HEADERS', originalEnv.trustedHeaders);
restoreEnvValue('SMARTPERFETTO_API_KEY', originalEnv.apiKey);
});

it('disables custom skill write endpoints in enterprise mode', async () => {
const create = await adminHeaders(request(app).post('/api/admin/skills'))
.send({ yaml: 'name: custom_skill\nversion: "1"\nsteps: []\n' })
.expect(404);
expect(create.body).toMatchObject({
error: 'disabled_in_enterprise_mode',
});

const update = await adminHeaders(request(app).put('/api/admin/skills/custom_skill'))
.send({ yaml: 'name: custom_skill\nversion: "2"\nsteps: []\n' })
.expect(404);
expect(update.body).toMatchObject({
error: 'disabled_in_enterprise_mode',
});

const remove = await adminHeaders(request(app).delete('/api/admin/skills/custom_skill'))
.expect(404);
expect(remove.body).toMatchObject({
error: 'disabled_in_enterprise_mode',
});
});

it('keeps non-writing validation available in enterprise mode', async () => {
const validYaml = [
'name: validation_only_skill',
'version: "1"',
'meta:',
' display_name: Validation Only Skill',
' description: Validates without persisting',
'steps:',
' - id: rows',
' type: atomic',
' sql: SELECT 1 AS value',
'',
].join('\n');

const res = await adminHeaders(request(app).post('/api/admin/skills/validate'))
.send({ yaml: validYaml })
.expect(200);

expect(res.body).toMatchObject({
valid: true,
errors: [],
});
});
});
26 changes: 22 additions & 4 deletions backend/src/routes/skillAdminRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,29 @@

import express from 'express';
import SkillAdminController from '../controllers/skillAdminController';
import { resolveFeatureConfig } from '../config';
import { authenticate } from '../middleware/auth';

const router = express.Router();
const skillAdminController = new SkillAdminController();

const CUSTOM_SKILL_DISABLED_PAYLOAD = {
error: 'disabled_in_enterprise_mode',
details: 'Custom skill write endpoints are disabled in enterprise mode.',
} as const;

function disableCustomSkillWritesInEnterpriseMode(
_req: express.Request,
res: express.Response,
next: express.NextFunction,
): void {
if (resolveFeatureConfig().enterprise) {
res.status(404).json(CUSTOM_SKILL_DISABLED_PAYLOAD);
return;
}
next();
}

// Admin endpoints must always be authenticated.
router.use(authenticate);

Expand Down Expand Up @@ -42,22 +60,22 @@ router.get('/skills/:skillId', skillAdminController.getSkill);
* Create a new custom skill
* Body: { yaml: string } or { definition: SkillDefinition }
*/
router.post('/skills', skillAdminController.createSkill);
router.post('/skills', disableCustomSkillWritesInEnterpriseMode, skillAdminController.createSkill);

/**
* PUT /api/admin/skills/:skillId
*
* Update an existing custom skill
* Body: { yaml: string } or { definition: SkillDefinition }
*/
router.put('/skills/:skillId', skillAdminController.updateSkill);
router.put('/skills/:skillId', disableCustomSkillWritesInEnterpriseMode, skillAdminController.updateSkill);

/**
* DELETE /api/admin/skills/:skillId
*
* Delete a custom skill
*/
router.delete('/skills/:skillId', skillAdminController.deleteSkill);
router.delete('/skills/:skillId', disableCustomSkillWritesInEnterpriseMode, skillAdminController.deleteSkill);

// =============================================================================
// Validation
Expand Down Expand Up @@ -96,4 +114,4 @@ router.get('/vendors', skillAdminController.listVendors);
*/
router.get('/vendors/:vendor/overrides', skillAdminController.getVendorOverrides);

export default router;
export default router;
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 Gracker (Chris)
// This file is part of SmartPerfetto. See LICENSE for details.

import fs from 'fs/promises';
import os from 'os';
import path from 'path';

import { SkillRegistry } from '../skillLoader';

describe('custom skill loading', () => {
let tmpDir: string;

beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-custom-skills-'));
});

afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});

it('loads skills from the custom directory after admin writes', async () => {
const customDir = path.join(tmpDir, 'custom');
await fs.mkdir(customDir, { recursive: true });
await fs.writeFile(
path.join(customDir, 'workspace_jank.skill.yaml'),
[
'name: workspace_jank',
'version: "1"',
'meta:',
' display_name: Workspace Jank',
' description: Local custom skill',
'steps:',
' - id: rows',
' type: atomic',
' sql: SELECT 1 AS value',
'',
].join('\n'),
'utf-8',
);

const registry = new SkillRegistry();
await registry.loadSkills(tmpDir);

expect(registry.getSkill('workspace_jank')).toMatchObject({
name: 'workspace_jank',
version: '1',
meta: {
display_name: 'Workspace Jank',
},
});
});
});
11 changes: 9 additions & 2 deletions backend/src/services/skillEngine/skillLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ export interface VendorOverride {
// Skill Registry
// =============================================================================

class SkillRegistry {
export class SkillRegistry {
private skills: Map<string, SkillDefinition> = new Map();
private moduleSkills: Map<string, SkillDefinition> = new Map(); // Skills with module metadata
private fragmentCache: Map<string, string> = new Map(); // SQL fragment path → content
Expand Down Expand Up @@ -575,6 +575,13 @@ class SkillRegistry {
await this.loadSkillsFromDir(compositeDir);
}

// 加载本地 custom skills. Enterprise v1 disables write endpoints, but
// non-enterprise admin writes must be readable after reload.
const customDir = path.join(skillsDir, 'custom');
if (fs.existsSync(customDir)) {
await this.loadSkillsFromDir(customDir);
}

// 加载深度分析 skills (Phase 6)
const deepDir = path.join(skillsDir, 'deep');
if (fs.existsSync(deepDir)) {
Expand Down Expand Up @@ -1095,4 +1102,4 @@ export async function ensureSkillRegistryInitialized(): Promise<void> {
*/
export function getSkillsDir(): string {
return path.resolve(__dirname, '../../../skills');
}
}
8 changes: 7 additions & 1 deletion docs/features/enterprise-multi-tenant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
- [x] 5.3 配额 / 预算 / retention policy(§16.1,含 quota_exceeded 终态)
- [x] 5.4 Tenant export bundle(§16.2,含 SHA256 + tenant identity proof)
- [x] 5.5 Tenant tombstone + 7 天硬删窗口 + async purge + audit proof(§16.3)
- [ ] 5.6 Custom skill v1 处置(§14.3):禁用 write endpoint 或修 loader 闭环
- [x] 5.6 Custom skill v1 处置(§14.3):禁用 write endpoint 或修 loader 闭环
- [ ] 5.7 Legacy AI route 处置(§14.4 表)
- [ ] 5.7.1 `/api/agent/v1/llm` DeepSeek proxy
- [ ] 5.7.2 `/api/advanced-ai`
Expand Down Expand Up @@ -909,6 +909,12 @@ v1 要求:
- 清理或修复 custom skill loader 的读写闭环,避免“写得进、读不出”。
- system skill 执行仍然必须带 RequestContext,并确保 SQL/MCP tool 不越过 trace owner guard。

当前实现:

- `POST/PUT/DELETE /api/admin/skills` 在 `SMARTPERFETTO_ENTERPRISE=true` 时返回 `404` + `disabled_in_enterprise_mode`,避免 v1 开放 workspace custom skill 写面。
- `SkillRegistry` 继续读取 `backend/skills/custom/`,让非企业本地 admin 写入后 reload 能读回,不再出现写入悬挂。
- `POST /api/admin/skills/validate`、`POST /api/admin/skills/reload` 和只读 skill/vendor 查询保持可用。

后续 feature 可做路 B:workspace-scoped skill registry。

### 14.4 Legacy AI 路径处置
Expand Down