Compare commits

...

3 Commits

Author SHA1 Message Date
hongjr03 3ee6da7ceb chore: release v0.0.29
Web-based skill management: create, read, edit, disable skills from the
org admin surface. File tree editor with SKILL.md manifest support.
2026-07-16 01:12:51 +08:00
hongjr03 79f72ecca8 feat(admin): web-based skill management with file editor
Add full skill lifecycle to the org-admin web surface: create, read,
edit, disable. Skills are directories (SKILL.md manifest + supporting
files), content-addressed by SHA-256 in an immutable store.

Backend:
- skillStore: extract commitSkillContent (shared populate→inspect→
  dedup→atomic rename); add importSkillFromFiles (in-memory file list
  ingestion) and readSkillFiles (read stored version back as UTF-8)
- configuration: add installSkillFromFiles, readSkillFiles, disableSkill
  (soft-delete + archive bound role sessions), updateSkillDescription
  (label-only, no archival); refactor installSkill to share
  commitInstalledSkill
- agentConfigRoutes: wire skillStoreRoot; add GET
  /agent-skills/:name/files, PUT /agent-skills/:name (create/replace),
  PATCH /agent-skills/:name (description/disable)
- orgRoutes: pass readSkillStoreRoot() to agent config routes

Frontend:
- api.ts: agentSkillFiles, installAgentSkill, patchAgentSkill methods
- SkillEditor.svelte: file tree + text editor + version/description form
- skills/+page.svelte: skill list, create form (generates SKILL.md
  template), per-skill editor
- layout: add 技能 nav item

ADR-0018: update Decision to reflect web surface joining host-console
CLI in the shared content-addressed ingestion pipeline.

Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web
installation is one implementation of it.
2026-07-16 01:11:49 +08:00
hongjr03 ae5f78f036 chore: release v0.0.28
Exempt SPA static assets and admin HTML shell from silo HTTP rate limit so
page loads no longer exhaust HUB_HTTP_REQUESTS_PER_MINUTE.
2026-07-15 22:33:07 +08:00
15 changed files with 960 additions and 49 deletions
@@ -123,15 +123,23 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
workspace or service-user config from widening tools, hooks, MCP servers, or workspace or service-user config from widening tools, hooks, MCP servers, or
sandbox paths. sandbox paths.
- Agent skills are Organization-scoped runtime configuration, not Hub release - Agent skills are Organization-scoped runtime configuration, not Hub release
assets. A controlled host-console installer imports each version into a assets. Skill content is imported into a content-addressed persistent store
content-addressed persistent store and records its digest in PostgreSQL. A through a shared ingestion pipeline (`importSkillDirectory` for host-console
role selects enabled Organization skills alongside its model, system prompt CLI, `importSkillFromFiles` for org-admin web surface) that enforces the same
and tool allowlist. Each run copies only those selected immutable versions safety checks: `SKILL.md` manifest required, 512-file / 16-byte limits,
into a run-scoped plugin outside the project workspace; the sandbox exposes symlink rejection, SHA-256 content addressing. The web surface
that snapshot read-only and deletes it after the run. SDK-bundled skills and (`OrganizationAgentConfiguration.installSkillFromFiles`) writes to the same
filesystem setting sources remain disabled, so project `.claude` content store as the host-console CLI (`installSkill`); both flow through
cannot register skills or widen tools. Requested skill versions are recorded `commitSkillContent` for deduplication and atomic rename into
on `run.created`; SDK initialization remains authoritative loading evidence. `versions/<digest>/`. Org-admin authentication gates the web surface; the
content-addressed store remains platform-controlled. A role selects enabled
Organization skills alongside its model, system prompt and tool allowlist.
Each run copies only those selected immutable versions into a run-scoped
plugin outside the project workspace; the sandbox exposes that snapshot
read-only and deletes it after the run. SDK-bundled skills and filesystem
setting sources remain disabled, so project `.claude` content cannot register
skills or widen tools. Requested skill versions are recorded on
`run.created`; SDK initialization remains authoritative loading evidence.
- Network: open (see Open Questions). - Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the `bypassPermissions` is kept (headless server — no interactive prompts); the
+17
View File
@@ -253,6 +253,17 @@ export interface AgentSkillRow {
boundRoleIds: readonly string[]; boundRoleIds: readonly string[];
} }
export interface SkillFileEntry {
path: string;
content: string;
}
export interface InstalledSkillResult {
id: string;
name: string;
contentDigest: string;
}
export interface AgentModelRow { export interface AgentModelRow {
id: string; id: string;
label: string; label: string;
@@ -392,5 +403,11 @@ export const api = {
skillNames: string[]; skillNames: string[];
}>, }>,
agentSkills: (slug: string) => get(`${orgBase(slug)}/agent-skills`) as Promise<{ skills: AgentSkillRow[] }>, agentSkills: (slug: string) => get(`${orgBase(slug)}/agent-skills`) as Promise<{ skills: AgentSkillRow[] }>,
agentSkillFiles: (slug: string, name: string) =>
get(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/files`) as Promise<{ files: SkillFileEntry[] }>,
installAgentSkill: (slug: string, name: string, body: { version: string; files: readonly SkillFileEntry[] }) =>
put(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<InstalledSkillResult>,
patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>,
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>, agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
}; };
@@ -0,0 +1,305 @@
<script lang="ts">
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
import { api } from '$lib/api';
import { fmtDate } from '$lib/format';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let {
slug,
skill,
oninstalled,
ondisabled,
}: {
slug: string;
skill: AgentSkillRow;
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
ondisabled: (name: string) => void;
} = $props();
type FileNode = { path: string; content: string };
let files = $state<FileNode[]>([]);
let selectedPath = $state<string | null>(null);
let version = $state(skill.version);
let description = $state(skill.description ?? '');
let loading = $state(false);
let saving = $state(false);
let dirty = $state(false);
let newFilePath = $state('');
let showNewFile = $state(false);
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
const sortedFiles = $derived([...files].sort((a, b) => a.path.localeCompare(b.path)));
async function loadFiles() {
loading = true;
try {
const res = await api.agentSkillFiles(slug, skill.name);
files = res.files.map((f) => ({ path: f.path, content: f.content }));
if (files.length > 0 && selectedPath === null) {
selectedPath = files[0]!.path;
}
dirty = false;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
loading = false;
}
}
function selectFile(path: string) {
selectedPath = path;
}
function updateContent(path: string, content: string) {
const file = files.find((f) => f.path === path);
if (file) {
file.content = content;
dirty = true;
if (path === 'SKILL.md') {
const desc = parseDescription(content);
if (desc !== null) description = desc;
}
}
}
function addFile() {
const path = newFilePath.trim();
if (path === '') {
toastError('文件路径不能为空');
return;
}
if (files.some((f) => f.path === path)) {
toastError(`文件已存在:${path}`);
return;
}
if (path.startsWith('/') || path.includes('..')) {
toastError('文件路径必须为相对路径');
return;
}
files.push({ path, content: '' });
selectedPath = path;
newFilePath = '';
showNewFile = false;
dirty = true;
}
function deleteFile(path: string) {
if (path === 'SKILL.md') {
toastError('SKILL.md 是必需的 manifest,不能删除');
return;
}
files = files.filter((f) => f.path !== path);
if (selectedPath === path) {
selectedPath = files.length > 0 ? files[0]!.path : null;
}
dirty = true;
}
function parseDescription(manifest: string): string | null {
const match = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(manifest);
return match ? match[1]!.trim() : null;
}
async function save() {
if (!hasManifest) {
toastError('缺少 SKILL.md manifest 文件');
return;
}
const trimmedVersion = version.trim();
if (trimmedVersion === '') {
toastError('版本号不能为空');
return;
}
saving = true;
try {
const fileEntries: SkillFileEntry[] = files.map((f) => ({ path: f.path, content: f.content }));
const result = await api.installAgentSkill(slug, skill.name, {
version: trimmedVersion,
files: fileEntries,
});
dirty = false;
toastSuccess('技能已保存');
oninstalled(result);
await loadFiles();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable() {
saving = true;
try {
await api.patchAgentSkill(slug, skill.name, { disabled: true });
toastSuccess('技能已禁用');
ondisabled(skill.name);
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
function saveDescription() {
const manifest = files.find((f) => f.path === 'SKILL.md');
if (!manifest) return;
const updated = updateFrontmatter(manifest.content, 'description', description.trim());
manifest.content = updated;
dirty = true;
}
function updateFrontmatter(content: string, key: string, value: string): string {
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
if (regex.test(content)) {
return content.replace(regex, `${key}: ${value}`);
}
const lines = content.split('\n');
if (lines[0] === '---') {
lines.splice(1, 0, `${key}: ${value}`);
} else {
lines.unshift('---', `${key}: ${value}`, '---');
}
return lines.join('\n');
}
$effect(() => {
if (skill.name) loadFiles();
});
</script>
<div class="saas-card-pad">
<div class="mb-4 flex flex-wrap items-center gap-2">
<span class="saas-badge-primary font-mono">{skill.name}</span>
<span class="text-sm text-surface-700">v{skill.version}</span>
{#if skill.disabledAt}
<span class="saas-badge-error">已禁用</span>
{/if}
</div>
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="skill-version-{skill.id}" class="saas-label">版本号</label>
<input id="skill-version-{skill.id}" class="saas-input" bind:value={version} placeholder="如 0.1.0" />
</div>
<div>
<label for="skill-desc-{skill.id}" class="saas-label">描述(同步到 SKILL.md frontmatter</label>
<div class="flex gap-2">
<input id="skill-desc-{skill.id}" class="saas-input" bind:value={description} onchange={saveDescription} />
</div>
</div>
</div>
<div class="mb-4 flex flex-wrap items-center gap-2 text-xs text-surface-600">
<span>digest: <code class="font-mono">{skill.contentDigest.slice(0, 12)}</code></span>
<span>·</span>
<span>更新于 {fmtDate(skill.updatedAt)}</span>
{#if skill.boundRoleIds.length > 0}
<span>·</span>
<span>绑定角色: {skill.boundRoleIds.join(', ')}</span>
{/if}
{#if dirty}
<span>·</span>
<span class="font-medium text-warning-700">未保存</span>
{/if}
</div>
{#if loading}
<div class="py-8 text-center text-sm text-surface-600">加载文件中…</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-[16rem_1fr]">
<div class="border border-surface-300 bg-surface-100">
<div class="flex items-center justify-between border-b border-surface-300 px-3 py-2">
<span class="text-xs font-medium text-surface-700">文件</span>
<button
type="button"
class="text-xs text-primary-700 hover:text-primary-900"
onclick={() => (showNewFile = !showNewFile)}
>
{showNewFile ? '取消' : '+ 新增'}
</button>
</div>
{#if showNewFile}
<div class="border-b border-surface-300 px-3 py-2">
<input
class="saas-input text-xs"
placeholder="如 reference.md"
bind:value={newFilePath}
onkeydown={(e) => {
if (e.key === 'Enter') addFile();
}}
/>
</div>
{/if}
<div class="max-h-80 overflow-y-auto">
{#each sortedFiles as f (f.path)}
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition hover:bg-surface-200
{selectedPath === f.path ? 'bg-primary-100 text-primary-900' : 'text-surface-800'}"
onclick={() => selectFile(f.path)}
>
<Icon name="file" class="h-3.5 w-3.5 shrink-0 opacity-60" />
<span class="truncate font-mono text-xs">{f.path}</span>
{#if f.path === 'SKILL.md'}
<span class="ml-auto text-[10px] text-primary-700">manifest</span>
{:else}
<span
class="ml-auto text-xs text-surface-500 hover:text-error-700"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
deleteFile(f.path);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
deleteFile(f.path);
}
}}
>
×
</span>
{/if}
</button>
{/each}
{#if files.length === 0}
<div class="px-3 py-4 text-center text-xs text-surface-600">暂无文件</div>
{/if}
</div>
</div>
<div>
{#if selectedFile}
<div class="mb-2 flex items-center gap-2">
<span class="font-mono text-xs text-surface-600">{selectedFile.path}</span>
</div>
<textarea
class="h-80 w-full resize-y border border-surface-300 bg-white p-3 font-mono text-xs text-surface-900 focus:border-primary-500 focus:outline-none"
bind:value={selectedFile.content}
oninput={() => (dirty = true)}
></textarea>
{:else}
<div class="flex h-80 items-center justify-center border border-surface-300 bg-surface-100 text-sm text-surface-600">
选择一个文件或新建文件
</div>
{/if}
</div>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
{saving ? '保存中…' : '保存'}
</button>
{#if !skill.disabledAt}
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
禁用
</button>
{/if}
</div>
{/if}
</div>
+1
View File
@@ -25,6 +25,7 @@
{ key: 'projects', label: '项目', icon: 'projects' as const }, { key: 'projects', label: '项目', icon: 'projects' as const },
{ key: 'capacity', label: '容量', icon: 'overview' as const }, { key: 'capacity', label: '容量', icon: 'overview' as const },
{ key: 'provider', label: '供应方', icon: 'provider' as const }, { key: 'provider', label: '供应方', icon: 'provider' as const },
{ key: 'skills', label: '技能', icon: 'roles' as const },
{ key: 'roles', label: '角色', icon: 'roles' as const }, { key: 'roles', label: '角色', icon: 'roles' as const },
{ key: 'feishu', label: '飞书', icon: 'feishu' as const }, { key: 'feishu', label: '飞书', icon: 'feishu' as const },
]; ];
@@ -0,0 +1,141 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkillEditor from '$lib/components/SkillEditor.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let skills = $state<AgentSkillRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let showNewSkill = $state(false);
let newSkillName = $state('');
let newSkillVersion = $state('0.1.0');
let newSkillDescription = $state('');
let creating = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.agentSkills(slug);
skills = res.skills;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function createSkill() {
const name = newSkillName.trim();
if (name === '') {
toastError('技能名称不能为空');
return;
}
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
toastError('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
return;
}
const version = newSkillVersion.trim();
if (version === '') {
toastError('版本号不能为空');
return;
}
creating = true;
try {
const manifest = buildManifest(name, newSkillDescription.trim());
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
const result = await api.installAgentSkill(slug, name, { version, files });
toastSuccess(`技能 ${result.name} 已创建`);
newSkillName = '';
newSkillDescription = '';
showNewSkill = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
creating = false;
}
}
function buildManifest(name: string, description: string): string {
const desc = description === '' ? name : description;
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
}
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
load();
}
function onDisabled(_name: string) {
load();
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="技能"
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<div class="flex items-center justify-between">
<h2 class="saas-section-title">新建技能</h2>
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
{showNewSkill ? '取消' : '+ 新建'}
</button>
</div>
{#if showNewSkill}
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="技能名(如 typst-help"
bind:value={newSkillName}
/>
<input
class="saas-input text-sm"
placeholder="版本号"
bind:value={newSkillVersion}
/>
<input
class="saas-input text-sm"
placeholder="描述"
bind:value={newSkillDescription}
/>
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
{creating ? '创建中…' : '创建'}
</button>
</div>
<p class="mt-2 text-xs text-surface-600">
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板。
</p>
{/if}
</div>
{#if skills.length === 0}
<div class="saas-card">
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
</div>
{:else}
<div class="space-y-4">
{#each skills as skill (skill.id)}
<SkillEditor {slug} {skill} oninstalled={onInstalled} ondisabled={onDisabled} />
{/each}
</div>
{/if}
{/if}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.27", "version": "0.0.29",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.27", "version": "0.0.29",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.27", "version": "0.0.29",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+115 -9
View File
@@ -1,13 +1,18 @@
/** /**
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018). * Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
* *
* Surface for browsing and editing Organization-scoped Agent roles and the * Surface for browsing and editing Organization-scoped Agent roles and skills.
* skills bound to them. Roles are org-owned data; the default-model picker is * Roles are org-owned data; the default-model picker is constrained to the
* constrained to the env-default model registry (ADR-0017: there is no * env-default model registry (ADR-0017: there is no org-scoped model list —
* org-scoped model list — `OrganizationAgentRole.defaultModel` selects from the * `OrganizationAgentRole.defaultModel` selects from the platform-enabled set).
* platform-enabled set). Skill *installation* is out of band (CLI / seed) per *
* spec `Skill 管理是 org-scoped; 存储机制 OPEN`; this route only lists installed * Skill management (create, read, edit, disable) is performed in-band through
* skills and binds them to roles. * the deep module `OrganizationAgentConfiguration`, which writes to the same
* content-addressed persistent store used by the host-console CLI. All skill
* content flows through `importSkillFromFiles` → `inspectSkillDirectory` →
* `commitSkillContent`, so the web path and CLI path share one ingestion
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file
* / 16-byte limits, symlink rejection).
*/ */
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
@@ -19,6 +24,7 @@ import { handleRouteError } from "../errors.js";
export interface AgentConfigRouteConfig { export interface AgentConfigRouteConfig {
readonly prisma: PrismaClient; readonly prisma: PrismaClient;
readonly sessionSecret: string; readonly sessionSecret: string;
readonly skillStoreRoot: string;
} }
export async function registerAgentConfigRoutes( export async function registerAgentConfigRoutes(
@@ -26,8 +32,7 @@ export async function registerAgentConfigRoutes(
config: AgentConfigRouteConfig, config: AgentConfigRouteConfig,
): Promise<void> { ): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
// skillStoreRoot is null: this surface does not install skills (see header). const agentConfig = new OrganizationAgentConfiguration(config.prisma, config.skillStoreRoot);
const agentConfig = new OrganizationAgentConfiguration(config.prisma, null);
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => { app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
try { try {
@@ -115,6 +120,107 @@ export async function registerAgentConfigRoutes(
} }
}); });
app.get("/api/org/:orgSlug/agent-skills/:name/files", async (request, reply) => {
try {
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
const skill = skills.find((s) => s.name === name && s.disabledAt === null);
if (skill === undefined) {
return reply.status(404).send({
error: { code: "not_found", message: `skill not found: ${name}` },
});
}
const files = await agentConfig.readSkillFiles({
organizationId: auth.organization.id,
contentDigest: skill.contentDigest,
});
return { files };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.put("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
try {
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { version?: unknown; files?: unknown };
if (typeof body.version !== "string" || body.version.trim() === "") {
return reply.status(400).send({
error: { code: "bad_request", message: "version is required" },
});
}
if (!Array.isArray(body.files) || body.files.length === 0) {
return reply.status(400).send({
error: { code: "bad_request", message: "files must be a non-empty array" },
});
}
const skillFiles: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
for (const entry of body.files) {
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
return reply.status(400).send({
error: { code: "bad_request", message: "each file must be an object" },
});
}
const e = entry as { path?: unknown; content?: unknown };
if (typeof e.path !== "string" || typeof e.content !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "each file needs string path and content" },
});
}
skillFiles.push({ path: e.path, bytes: Buffer.from(e.content, "utf8") });
}
const result = await agentConfig.installSkillFromFiles({
organizationId: auth.organization.id,
files: skillFiles,
version: body.version,
});
// The manifest name is authoritative — reject if it doesn't match the
// URL param, so clients can't silently rename a skill under a different
// route key.
if (result.name !== name) {
return reply.status(400).send({
error: {
code: "bad_request",
message: `skill manifest name "${result.name}" does not match route "${name}"`,
},
});
}
return { id: result.id, name: result.name, contentDigest: result.contentDigest };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
try {
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { description?: unknown; disabled?: unknown };
if (body.disabled === true) {
await agentConfig.disableSkill({ organizationId: auth.organization.id, name });
return { disabled: true };
}
if (typeof body.description === "string") {
await agentConfig.updateSkillDescription({
organizationId: auth.organization.id,
name,
description: body.description,
});
return { updated: true };
}
return reply.status(400).send({
error: { code: "bad_request", message: "provide description or disabled: true" },
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/agent-models", async (request, reply) => { app.get("/api/org/:orgSlug/agent-models", async (request, reply) => {
try { try {
const { orgSlug } = request.params as { orgSlug: string }; const { orgSlug } = request.params as { orgSlug: string };
+2
View File
@@ -16,6 +16,7 @@ import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
import { registerTeamsRoutes } from "./teamsRoutes.js"; import { registerTeamsRoutes } from "./teamsRoutes.js";
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js"; import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
import { registerCapacityRoutes } from "./capacityRoutes.js"; import { registerCapacityRoutes } from "./capacityRoutes.js";
import { readSkillStoreRoot } from "../../agent/skillStore.js";
import { registerAgentConfigRoutes } from "./agentConfigRoutes.js"; import { registerAgentConfigRoutes } from "./agentConfigRoutes.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js"; import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
@@ -114,6 +115,7 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
await registerAgentConfigRoutes(app, { await registerAgentConfigRoutes(app, {
prisma: config.prisma, prisma: config.prisma,
sessionSecret: config.sessionSecret, sessionSecret: config.sessionSecret,
skillStoreRoot: readSkillStoreRoot(),
}); });
await registerSessionsAndUsageRoutes(app, { await registerSessionsAndUsageRoutes(app, {
prisma: config.prisma, prisma: config.prisma,
+157 -16
View File
@@ -1,7 +1,7 @@
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { assertSupportedRoleTools } from "./roleTools.js"; import { assertSupportedRoleTools } from "./roleTools.js";
import { importSkillDirectory } from "./skillStore.js"; import { importSkillDirectory, importSkillFromFiles, readSkillFiles, type SkillFileInput, type SkillFileOutput } from "./skillStore.js";
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
@@ -88,38 +88,179 @@ export class OrganizationAgentConfiguration {
readonly sourceDir: string; readonly sourceDir: string;
readonly version: string; readonly version: string;
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> { }): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
if (this.skillStoreRoot === null) { const storeRoot = this.requireSkillStoreRoot();
throw new Error("skill store root is not configured for this OrganizationAgentConfiguration");
}
await this.requireActiveOrganization(input.organizationId); await this.requireActiveOrganization(input.organizationId);
const version = nonEmpty(input.version, "skill version"); const version = nonEmpty(input.version, "skill version");
const imported = await importSkillDirectory({ const imported = await importSkillDirectory({
sourceDir: input.sourceDir, sourceDir: input.sourceDir,
storeRoot: this.skillStoreRoot, storeRoot,
}); });
return this.commitInstalledSkill({
organizationId: input.organizationId,
imported,
version,
action: "agent_skill.installed",
});
}
/**
* Install or replace a skill from an in-memory file list (web upload path).
* Same content-addressed storage, same session archival semantics as
* `installSkill`; only the ingestion source differs.
*/
async installSkillFromFiles(input: {
readonly organizationId: string;
readonly files: readonly SkillFileInput[];
readonly version: string;
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
const storeRoot = this.requireSkillStoreRoot();
await this.requireActiveOrganization(input.organizationId);
const version = nonEmpty(input.version, "skill version");
const imported = await importSkillFromFiles({
files: input.files,
storeRoot,
});
return this.commitInstalledSkill({
organizationId: input.organizationId,
imported,
version,
action: "agent_skill.installed",
});
}
/**
* Read all files from the stored skill version identified by content digest.
* Returns UTF-8 content for each file; the web editor uses this to populate
* its file tree.
*/
async readSkillFiles(input: {
readonly organizationId: string;
readonly contentDigest: string;
}): Promise<readonly SkillFileOutput[]> {
const storeRoot = this.requireSkillStoreRoot();
await this.requireActiveOrganization(input.organizationId);
const skill = await this.prisma.organizationAgentSkill.findFirst({
where: {
organizationId: input.organizationId,
contentDigest: input.contentDigest,
},
select: { id: true, disabledAt: true },
});
if (skill === null) {
throw new Error(`skill not found in organization for digest: ${input.contentDigest}`);
}
return readSkillFiles({
storeRoot,
contentDigest: input.contentDigest,
});
}
/**
* Disable a skill (soft-delete). Sets `disabledAt` and archives active
* sessions of every role bound to this skill, since the execution surface
* changes when a bound skill disappears.
*/
async disableSkill(input: { readonly organizationId: string; readonly name: string }): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const skill = await tx.organizationAgentSkill.findUnique({
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
select: { id: true, disabledAt: true, roleBindings: { select: { role: { select: { roleId: true } } } } },
});
if (skill === null) throw new Error(`active skill not found in organization: ${input.name}`);
if (skill.disabledAt !== null) return;
await tx.organizationAgentSkill.update({
where: { id: skill.id },
data: { disabledAt: new Date() },
});
await archiveRoleSessions(
tx,
input.organizationId,
skill.roleBindings.map((binding) => binding.role.roleId),
);
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_skill.disabled",
metadata: { name: input.name },
},
});
});
}
/**
* Update only the `description` label of a skill. This is a label change,
* not an execution-surface change — no session archival (ADR-0017).
*/
async updateSkillDescription(input: {
readonly organizationId: string;
readonly name: string;
readonly description: string;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
const description = input.description.trim();
await this.prisma.$transaction(async (tx) => {
const skill = await tx.organizationAgentSkill.findUnique({
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
select: { id: true, disabledAt: true },
});
if (skill === null || skill.disabledAt !== null) {
throw new Error(`active skill not found in organization: ${input.name}`);
}
await tx.organizationAgentSkill.update({
where: { id: skill.id },
data: { description: description === "" ? null : description },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_skill.description_updated",
metadata: { name: input.name, description: description === "" ? null : description },
},
});
});
}
private requireSkillStoreRoot(): string {
if (this.skillStoreRoot === null) {
throw new Error("skill store root is not configured for this OrganizationAgentConfiguration");
}
return this.skillStoreRoot;
}
/**
* Shared DB commit for an imported skill: upsert the skill record, archive
* affected sessions if the content digest changed, and write an audit entry.
*/
private async commitInstalledSkill(input: {
readonly organizationId: string;
readonly imported: { readonly name: string; readonly description: string | undefined; readonly contentDigest: string };
readonly version: string;
readonly action: string;
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const previous = await tx.organizationAgentSkill.findUnique({ const previous = await tx.organizationAgentSkill.findUnique({
where: { organizationId_name: { organizationId: input.organizationId, name: imported.name } }, where: { organizationId_name: { organizationId: input.organizationId, name: input.imported.name } },
select: { contentDigest: true }, select: { contentDigest: true },
}); });
const skill = await tx.organizationAgentSkill.upsert({ const skill = await tx.organizationAgentSkill.upsert({
where: { where: {
organizationId_name: { organizationId_name: {
organizationId: input.organizationId, organizationId: input.organizationId,
name: imported.name, name: input.imported.name,
}, },
}, },
create: { create: {
organizationId: input.organizationId, organizationId: input.organizationId,
name: imported.name, name: input.imported.name,
version, version: input.version,
description: imported.description ?? null, description: input.imported.description ?? null,
contentDigest: imported.contentDigest, contentDigest: input.imported.contentDigest,
}, },
update: { update: {
version, version: input.version,
description: imported.description ?? null, description: input.imported.description ?? null,
contentDigest: imported.contentDigest, contentDigest: input.imported.contentDigest,
disabledAt: null, disabledAt: null,
}, },
select: { select: {
@@ -139,10 +280,10 @@ export class OrganizationAgentConfiguration {
await tx.auditEntry.create({ await tx.auditEntry.create({
data: { data: {
organizationId: input.organizationId, organizationId: input.organizationId,
action: "agent_skill.installed", action: input.action,
metadata: { metadata: {
name: skill.name, name: skill.name,
version, version: input.version,
contentDigest: skill.contentDigest, contentDigest: skill.contentDigest,
}, },
}, },
+102 -8
View File
@@ -34,40 +34,134 @@ export async function importSkillDirectory(input: {
readonly sourceDir: string; readonly sourceDir: string;
readonly storeRoot: string; readonly storeRoot: string;
}): Promise<ImportedSkillContent> { }): Promise<ImportedSkillContent> {
const source = await inspectSkillDirectory(input.sourceDir); return commitSkillContent({
storeRoot: input.storeRoot,
prepare: async (dir) => {
await cp(input.sourceDir, dir, { recursive: true, force: false, errorOnExist: true });
return inspectSkillDirectory(dir);
},
});
}
/**
* Import a skill from an in-memory file list (web upload path). Each file
* path is relative to the skill root and must be a simple relative path
* (no absolute, no `..`). A `SKILL.md` manifest is required.
*/
export async function importSkillFromFiles(input: {
readonly files: readonly SkillFileInput[];
readonly storeRoot: string;
}): Promise<ImportedSkillContent> {
const seen = new Set<string>();
for (const file of input.files) {
validateSkillFilePath(file.path);
if (seen.has(file.path)) throw new Error(`duplicate skill file path: ${file.path}`);
seen.add(file.path);
}
return commitSkillContent({
storeRoot: input.storeRoot,
prepare: async (dir) => {
for (const file of input.files) {
const dest = join(dir, file.path);
const parent = join(dest, "..");
await mkdir(parent, { recursive: true, mode: 0o750 });
await writeFile(dest, file.bytes, { mode: 0o640 });
}
return inspectSkillDirectory(dir);
},
});
}
/**
* Read all files from a stored skill version (by content digest). Returns
* relative paths and UTF-8 decoded content for the web editor.
*/
export async function readSkillFiles(input: {
readonly storeRoot: string;
readonly contentDigest: string;
}): Promise<readonly SkillFileOutput[]> {
if (!DIGEST_PATTERN.test(input.contentDigest)) {
throw new Error(`invalid content digest: ${input.contentDigest}`);
}
const dir = join(input.storeRoot, "versions", input.contentDigest);
const files: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
await walk(resolve(dir), resolve(dir), files);
return files.sort((a, b) => a.path.localeCompare(b.path)).map((f) => ({
path: f.path,
content: f.bytes.toString("utf8"),
}));
}
export interface SkillFileInput {
readonly path: string;
readonly bytes: Buffer;
}
export interface SkillFileOutput {
readonly path: string;
readonly content: string;
}
/**
* Shared commit logic: populate a temp dir, inspect it, deduplicate against
* an existing `versions/<digest>/` destination, and atomically rename.
*
* `prepare(dir)` writes the skill content into `dir` and returns the inspected
* result (name, description, contentDigest). The caller owns the write;
* commitSkillContent owns the move.
*/
async function commitSkillContent(input: {
readonly storeRoot: string;
readonly prepare: (dir: string) => Promise<ImportedSkillContent>;
}): Promise<ImportedSkillContent> {
const versionsRoot = join(input.storeRoot, "versions"); const versionsRoot = join(input.storeRoot, "versions");
await mkdir(versionsRoot, { recursive: true, mode: 0o750 }); await mkdir(versionsRoot, { recursive: true, mode: 0o750 });
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
let source: ImportedSkillContent;
try {
source = await input.prepare(temporary);
} catch (error) {
await rm(temporary, { recursive: true, force: true });
throw error;
}
const destination = join(versionsRoot, source.contentDigest); const destination = join(versionsRoot, source.contentDigest);
// If the destination already exists with identical content, reuse it.
try { try {
const existing = await inspectSkillDirectory(destination); const existing = await inspectSkillDirectory(destination);
if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) { if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) {
throw new Error(`stored skill content digest mismatch: ${source.name}`); throw new Error(`stored skill content digest mismatch: ${source.name}`);
} }
await rm(temporary, { recursive: true, force: true });
return source; return source;
} catch (error) { } catch (error) {
if (!isMissingPath(error)) throw error; if (!isMissingPath(error)) throw error;
} }
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
try { try {
await cp(input.sourceDir, temporary, { recursive: true, force: false, errorOnExist: true });
const copied = await inspectSkillDirectory(temporary);
if (copied.contentDigest !== source.contentDigest || copied.name !== source.name) {
throw new Error(`skill changed while importing: ${source.name}`);
}
await rename(temporary, destination); await rename(temporary, destination);
} catch (error) { } catch (error) {
await rm(temporary, { recursive: true, force: true }); await rm(temporary, { recursive: true, force: true });
if (isDestinationExists(error)) { if (isDestinationExists(error)) {
const existing = await inspectSkillDirectory(destination); const existing = await inspectSkillDirectory(destination);
if (existing.contentDigest === source.contentDigest && existing.name === source.name) return source; if (existing.contentDigest === source.contentDigest && existing.name === source.name) {
return source;
}
} }
throw error; throw error;
} }
return source; return source;
} }
function validateSkillFilePath(path: string): void {
if (path === "") throw new Error("skill file path is empty");
if (path.startsWith("/")) throw new Error(`skill file path must be relative: ${path}`);
if (path.includes("..")) throw new Error(`skill file path must not escape: ${path}`);
if (path.startsWith("\\")) throw new Error(`skill file path must be relative: ${path}`);
}
export async function prepareRunSkillPlugin(input: { export async function prepareRunSkillPlugin(input: {
readonly storeRoot: string; readonly storeRoot: string;
readonly runId: string; readonly runId: string;
+24
View File
@@ -1,3 +1,27 @@
/**
* Silo-wide HTTP request rate limit (ADR-0022 `requestRate`).
*
* Counts dynamic traffic only: APIs, auth, and other application handlers.
* Static SPA assets and the org-admin HTML shell are exempt so a single page
* load (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
*/
/** Paths that must not consume the silo HTTP request-rate budget. */
export function isSiloHttpRateLimitExempt(url: string): boolean {
const path = (url.split("?", 1)[0] ?? url) || "/";
if (path === "/api/healthz") return true;
// SvelteKit build output and top-level static files (see admin/static.ts).
if (path === "/_app" || path.startsWith("/_app/")) return true;
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
// SPA index shell for client-side routes (not an API).
if (path === "/admin" || path.startsWith("/admin/")) return true;
return false;
}
export class SiloFixedWindowRateLimiter { export class SiloFixedWindowRateLimiter {
private windowStartedAt: number; private windowStartedAt: number;
private used = 0; private used = 0;
+4 -2
View File
@@ -14,7 +14,7 @@ import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuAppl
import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js"; import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js";
import { readServerBinding } from "./settings/server.js"; import { readServerBinding } from "./settings/server.js";
import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js"; import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js";
import { SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js"; import { isSiloHttpRateLimitExempt, SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
function requireEnv(name: string): string { function requireEnv(name: string): string {
const value = process.env[name]; const value = process.env[name];
@@ -42,7 +42,9 @@ export async function startHub(): Promise<void> {
const app = Fastify({ logger: true, bodyLimit: httpBodyLimit }); const app = Fastify({ logger: true, bodyLimit: httpBodyLimit });
const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000); const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000);
app.addHook("onRequest", async (request, reply) => { app.addHook("onRequest", async (request, reply) => {
if (request.url === "/api/healthz") return; // Static SPA assets / admin HTML shell / healthz do not count toward the
// silo requestRate budget (a full admin load can pull dozens of chunks).
if (isSiloHttpRateLimitExempt(request.url)) return;
const decision = requestLimiter.consume(); const decision = requestLimiter.consume();
if (!decision.allowed) { if (!decision.allowed) {
await reply await reply
+25 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { SiloFixedWindowRateLimiter } from "../../src/deployment/siloRateLimit.js"; import {
isSiloHttpRateLimitExempt,
SiloFixedWindowRateLimiter,
} from "../../src/deployment/siloRateLimit.js";
describe("Silo fixed-window rate limiter", () => { describe("Silo fixed-window rate limiter", () => {
it("returns an explicit retry interval and resets at the next window", () => { it("returns an explicit retry interval and resets at the next window", () => {
@@ -10,3 +13,24 @@ describe("Silo fixed-window rate limiter", () => {
expect(limiter.consume(61_000)).toEqual({ allowed: true }); expect(limiter.consume(61_000)).toEqual({ allowed: true });
}); });
}); });
describe("isSiloHttpRateLimitExempt", () => {
it("exempts healthz, SPA static assets, and the admin HTML shell", () => {
expect(isSiloHttpRateLimitExempt("/api/healthz")).toBe(true);
expect(isSiloHttpRateLimitExempt("/_app/version.json")).toBe(true);
expect(isSiloHttpRateLimitExempt("/_app/immutable/chunks/foo.js?v=1")).toBe(true);
expect(isSiloHttpRateLimitExempt("/favicon.ico")).toBe(true);
expect(isSiloHttpRateLimitExempt("/favicon.svg")).toBe(true);
expect(isSiloHttpRateLimitExempt("/robots.txt")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin")).toBe(true);
expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true);
});
it("still rate-limits APIs and auth", () => {
expect(isSiloHttpRateLimitExempt("/api/me")).toBe(false);
expect(isSiloHttpRateLimitExempt("/api/org/x/members")).toBe(false);
expect(isSiloHttpRateLimitExempt("/auth/feishu/x")).toBe(false);
expect(isSiloHttpRateLimitExempt("/auth/feishu/callback?code=1")).toBe(false);
expect(isSiloHttpRateLimitExempt("/")).toBe(false);
});
});
+47 -1
View File
@@ -2,7 +2,12 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promis
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { importSkillDirectory, prepareRunSkillPlugin } from "../../src/agent/skillStore.js"; import {
importSkillDirectory,
importSkillFromFiles,
prepareRunSkillPlugin,
readSkillFiles,
} from "../../src/agent/skillStore.js";
describe("content-addressed Agent skill store", () => { describe("content-addressed Agent skill store", () => {
const roots: string[] = []; const roots: string[] = [];
@@ -55,6 +60,47 @@ describe("content-addressed Agent skill store", () => {
})).rejects.toThrow(/content digest mismatch/); })).rejects.toThrow(/content digest mismatch/);
}); });
it("imports a skill from an in-memory file list and reads it back", async () => {
const root = await makeRoot();
const storeRoot = join(root, "store");
const manifest = `---\nname: web-skill\ndescription: Web uploaded\n---\n# web-skill\n`;
const files = [
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
{ path: "reference.md", bytes: Buffer.from("ref\n", "utf8") },
];
const installed = await importSkillFromFiles({ files, storeRoot });
expect(installed).toMatchObject({ name: "web-skill", description: "Web uploaded" });
expect(installed.contentDigest).toMatch(/^[a-f0-9]{64}$/);
const readBack = await readSkillFiles({ storeRoot, contentDigest: installed.contentDigest });
expect(readBack).toHaveLength(2);
const byPath = Object.fromEntries(readBack.map((f) => [f.path, f.content]));
expect(byPath["SKILL.md"]).toContain("name: web-skill");
expect(byPath["reference.md"]).toBe("ref\n");
});
it("rejects file paths that escape the skill root", async () => {
const root = await makeRoot();
const storeRoot = join(root, "store");
const manifest = `---\nname: escape\ndescription: test\n---\n# escape\n`;
const files = [
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
{ path: "../escape.md", bytes: Buffer.from("x", "utf8") },
];
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/must not escape/);
});
it("rejects duplicate file paths in importSkillFromFiles", async () => {
const root = await makeRoot();
const storeRoot = join(root, "store");
const manifest = `---\nname: dup\ndescription: test\n---\n# dup\n`;
const files = [
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
{ path: "SKILL.md", bytes: Buffer.from(manifest, "utf8") },
];
await expect(importSkillFromFiles({ files, storeRoot })).rejects.toThrow(/duplicate skill file path/);
});
async function makeRoot(): Promise<string> { async function makeRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-skill-store-")); const root = await mkdtemp(join(tmpdir(), "cph-skill-store-"));
roots.push(root); roots.push(root);