forked from EduCraft/curriculum-project-hub
feat: make agent roles and skills dynamic
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { assertSupportedRoleTools } from "./roleTools.js";
|
||||
import { importSkillDirectory } from "./skillStore.js";
|
||||
|
||||
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
/**
|
||||
* Deep module for controlled host-console Agent configuration. It owns the
|
||||
* filesystem/DB ordering, Organization checks and role-skill composition so
|
||||
* callers never manipulate registry rows or content paths independently.
|
||||
*/
|
||||
export class OrganizationAgentConfiguration {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly skillStoreRoot: string,
|
||||
) {}
|
||||
|
||||
async installSkill(input: {
|
||||
readonly organizationId: string;
|
||||
readonly sourceDir: string;
|
||||
readonly version: string;
|
||||
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const version = nonEmpty(input.version, "skill version");
|
||||
const imported = await importSkillDirectory({
|
||||
sourceDir: input.sourceDir,
|
||||
storeRoot: this.skillStoreRoot,
|
||||
});
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.organizationAgentSkill.findUnique({
|
||||
where: { organizationId_name: { organizationId: input.organizationId, name: imported.name } },
|
||||
select: { contentDigest: true },
|
||||
});
|
||||
const skill = await tx.organizationAgentSkill.upsert({
|
||||
where: {
|
||||
organizationId_name: {
|
||||
organizationId: input.organizationId,
|
||||
name: imported.name,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
name: imported.name,
|
||||
version,
|
||||
description: imported.description ?? null,
|
||||
contentDigest: imported.contentDigest,
|
||||
},
|
||||
update: {
|
||||
version,
|
||||
description: imported.description ?? null,
|
||||
contentDigest: imported.contentDigest,
|
||||
disabledAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
contentDigest: true,
|
||||
roleBindings: { select: { role: { select: { roleId: true } } } },
|
||||
},
|
||||
});
|
||||
if (previous !== null && previous.contentDigest !== skill.contentDigest) {
|
||||
await archiveRoleSessions(
|
||||
tx,
|
||||
input.organizationId,
|
||||
skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
);
|
||||
}
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_skill.installed",
|
||||
metadata: {
|
||||
name: skill.name,
|
||||
version,
|
||||
contentDigest: skill.contentDigest,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { id: skill.id, name: skill.name, contentDigest: skill.contentDigest };
|
||||
});
|
||||
}
|
||||
|
||||
async upsertRole(input: {
|
||||
readonly organizationId: string;
|
||||
readonly roleId: string;
|
||||
readonly label: string;
|
||||
readonly defaultModel?: string | null | undefined;
|
||||
readonly systemPrompt?: string | null | undefined;
|
||||
readonly tools?: readonly string[] | null | undefined;
|
||||
readonly sortOrder?: number | undefined;
|
||||
}): Promise<{ readonly id: string; readonly roleId: string }> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`);
|
||||
const label = nonEmpty(input.label, "role label");
|
||||
if (input.tools !== undefined && input.tools !== null) assertSupportedRoleTools([...input.tools]);
|
||||
const sortOrder = input.sortOrder ?? 0;
|
||||
if (!Number.isSafeInteger(sortOrder)) throw new Error("role sortOrder must be an integer");
|
||||
const createTools = input.tools === undefined || input.tools === null
|
||||
? Prisma.DbNull
|
||||
: [...input.tools];
|
||||
const updateTools = input.tools === undefined
|
||||
? undefined
|
||||
: input.tools === null
|
||||
? Prisma.DbNull
|
||||
: [...input.tools];
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.organizationAgentRole.findUnique({
|
||||
where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } },
|
||||
select: { defaultModel: true, systemPrompt: true, tools: true },
|
||||
});
|
||||
const role = await tx.organizationAgentRole.upsert({
|
||||
where: {
|
||||
organizationId_roleId: {
|
||||
organizationId: input.organizationId,
|
||||
roleId: input.roleId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
roleId: input.roleId,
|
||||
label,
|
||||
defaultModel: normalizeOptionalText(input.defaultModel),
|
||||
systemPrompt: normalizeOptionalText(input.systemPrompt),
|
||||
tools: createTools,
|
||||
sortOrder,
|
||||
},
|
||||
update: {
|
||||
label,
|
||||
...(input.defaultModel !== undefined ? { defaultModel: normalizeOptionalText(input.defaultModel) } : {}),
|
||||
...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}),
|
||||
...(updateTools !== undefined ? { tools: updateTools } : {}),
|
||||
sortOrder,
|
||||
disabledAt: null,
|
||||
},
|
||||
select: { id: true, roleId: true },
|
||||
});
|
||||
const executionSurfaceChanged = previous !== null && (
|
||||
(input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) ||
|
||||
(input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) ||
|
||||
(input.tools !== undefined && JSON.stringify(input.tools) !== JSON.stringify(previous.tools))
|
||||
);
|
||||
if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_role.upserted",
|
||||
metadata: {
|
||||
roleId: input.roleId,
|
||||
label,
|
||||
defaultModel: input.defaultModel === undefined ? "unchanged" : normalizeOptionalText(input.defaultModel),
|
||||
systemPromptConfigured: input.systemPrompt === undefined
|
||||
? "unchanged"
|
||||
: normalizeOptionalText(input.systemPrompt) !== null,
|
||||
tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools],
|
||||
sortOrder,
|
||||
},
|
||||
},
|
||||
});
|
||||
return role;
|
||||
});
|
||||
}
|
||||
|
||||
async setRoleSkills(input: {
|
||||
readonly organizationId: string;
|
||||
readonly roleId: string;
|
||||
readonly skillNames: readonly string[];
|
||||
}): Promise<void> {
|
||||
const uniqueNames = new Set(input.skillNames);
|
||||
if (uniqueNames.size !== input.skillNames.length) throw new Error("role skill names must be unique");
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const role = await tx.organizationAgentRole.findUnique({
|
||||
where: {
|
||||
organizationId_roleId: {
|
||||
organizationId: input.organizationId,
|
||||
roleId: input.roleId,
|
||||
},
|
||||
},
|
||||
select: { id: true, disabledAt: true },
|
||||
});
|
||||
if (role === null || role.disabledAt !== null) {
|
||||
throw new Error(`active role not found in organization: ${input.roleId}`);
|
||||
}
|
||||
const skills = await tx.organizationAgentSkill.findMany({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
name: { in: [...input.skillNames] },
|
||||
disabledAt: null,
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (skills.length !== input.skillNames.length) {
|
||||
const found = new Set(skills.map((skill) => skill.name));
|
||||
const missing = input.skillNames.filter((name) => !found.has(name));
|
||||
throw new Error(`active skills not found in organization: ${missing.join(", ")}`);
|
||||
}
|
||||
const byName = new Map(skills.map((skill) => [skill.name, skill.id]));
|
||||
await tx.organizationAgentRoleSkill.deleteMany({
|
||||
where: { organizationId: input.organizationId, agentRoleId: role.id },
|
||||
});
|
||||
if (input.skillNames.length > 0) {
|
||||
await tx.organizationAgentRoleSkill.createMany({
|
||||
data: input.skillNames.map((name, index) => ({
|
||||
organizationId: input.organizationId,
|
||||
agentRoleId: role.id,
|
||||
agentSkillId: byName.get(name)!,
|
||||
sortOrder: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_role.skills_set",
|
||||
metadata: { roleId: input.roleId, skillNames: [...input.skillNames] },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async requireActiveOrganization(organizationId: string): Promise<void> {
|
||||
const organization = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
|
||||
if (organization.status !== "ACTIVE") {
|
||||
throw new Error(`organization ${organizationId} is ${organization.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveRoleSessions(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
roleIds: readonly string[],
|
||||
): Promise<void> {
|
||||
if (roleIds.length === 0) return;
|
||||
await tx.agentSession.updateMany({
|
||||
where: {
|
||||
roleId: { in: [...new Set(roleIds)] },
|
||||
archivedAt: null,
|
||||
project: { organizationId },
|
||||
},
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
function nonEmpty(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized === "") throw new Error(`${label} is required`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value: string | null | undefined): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const normalized = value.trim();
|
||||
return normalized === "" ? null : normalized;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { lstat, readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const CURATED_SKILL_PLUGIN_NAME = "cph-curated";
|
||||
export const CURATED_SKILL_NAMES = [
|
||||
"outline",
|
||||
"lesson-project",
|
||||
"data-processing-spec",
|
||||
] as const;
|
||||
export const CURATED_SKILL_IDS = CURATED_SKILL_NAMES.map(
|
||||
(name) => `${CURATED_SKILL_PLUGIN_NAME}:${name}`,
|
||||
);
|
||||
|
||||
export interface CuratedSkillPlugin {
|
||||
readonly root: string;
|
||||
readonly skillIds: readonly string[];
|
||||
}
|
||||
|
||||
/** Validate the immutable, release-owned plugin before exposing it read-only. */
|
||||
export async function validateCuratedSkillPlugin(
|
||||
root = curatedSkillPluginRoot(),
|
||||
): Promise<CuratedSkillPlugin> {
|
||||
await assertExactDirectoryEntries(root, [".claude-plugin", "skills"], "");
|
||||
await assertExactDirectoryEntries(join(root, ".claude-plugin"), ["plugin.json"], ".claude-plugin");
|
||||
await assertExactDirectoryEntries(join(root, "skills"), CURATED_SKILL_NAMES, "skills");
|
||||
|
||||
const pluginManifest = join(root, ".claude-plugin", "plugin.json");
|
||||
let pluginName: unknown;
|
||||
try {
|
||||
const stat = await lstat(pluginManifest);
|
||||
if (!stat.isFile()) throw new Error(`not a regular file: ${pluginManifest}`);
|
||||
pluginName = JSON.parse(await readFile(pluginManifest, "utf8")).name;
|
||||
} catch (error) {
|
||||
throw new Error(`curated skill plugin manifest invalid: ${pluginManifest}`, { cause: error });
|
||||
}
|
||||
if (pluginName !== CURATED_SKILL_PLUGIN_NAME) {
|
||||
throw new Error(`curated skill plugin name mismatch: expected ${CURATED_SKILL_PLUGIN_NAME}, got ${String(pluginName)}`);
|
||||
}
|
||||
|
||||
for (const name of CURATED_SKILL_NAMES) {
|
||||
const manifest = join(root, "skills", name, "SKILL.md");
|
||||
try {
|
||||
const stat = await lstat(manifest);
|
||||
if (!stat.isFile()) throw new Error(`not a regular file: ${manifest}`);
|
||||
const contents = await readFile(manifest, "utf8");
|
||||
const declaredName = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(contents)?.[1]?.trim();
|
||||
if (declaredName !== name) {
|
||||
throw new Error(`curated skill manifest name mismatch: expected ${name}, got ${declaredName ?? "missing"}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("curated skill manifest name mismatch:")) throw error;
|
||||
throw new Error(`curated skill source missing: ${manifest}`, { cause: error });
|
||||
}
|
||||
}
|
||||
return { root, skillIds: CURATED_SKILL_IDS };
|
||||
}
|
||||
|
||||
export function curatedSkillPluginRoot(): string {
|
||||
return fileURLToPath(new URL("../../curated-skills-plugin/", import.meta.url));
|
||||
}
|
||||
|
||||
async function assertExactDirectoryEntries(
|
||||
directory: string,
|
||||
allowedNames: readonly string[],
|
||||
relativeDirectory: string,
|
||||
): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
throw new Error(`curated plugin directory missing: ${directory}`, { cause: error });
|
||||
}
|
||||
const allowed = new Set(allowedNames);
|
||||
for (const entry of entries) {
|
||||
if (!allowed.has(entry.name)) {
|
||||
const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`;
|
||||
throw new Error(`unexpected curated plugin entry: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
for (const name of allowedNames) {
|
||||
if (!entries.some((entry) => entry.name === name)) {
|
||||
const relativePath = relativeDirectory === "" ? name : `${relativeDirectory}/${name}`;
|
||||
throw new Error(`curated plugin entry missing: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,14 @@ export interface RoleEntry {
|
||||
* Invalid names fail fast when settings are loaded or the run is set up.
|
||||
*/
|
||||
readonly tools?: readonly string[] | undefined;
|
||||
/** Immutable skill versions selected by this role at runtime. */
|
||||
readonly skills?: readonly RoleSkillEntry[] | undefined;
|
||||
}
|
||||
|
||||
export interface RoleSkillEntry {
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly contentDigest: string;
|
||||
}
|
||||
|
||||
/** A model the admin has enabled for use by the Hub. */
|
||||
|
||||
+13
-2
@@ -33,6 +33,7 @@ import { query, type HookCallback, type McpServerConfig, type SDKMessage, type S
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { claudeSdkToolConfigForRole } from "./roleTools.js";
|
||||
import { createAgentSecurityPolicy } from "./security.js";
|
||||
import type { RoleSkillEntry } from "./models.js";
|
||||
|
||||
export interface ProjectContext {
|
||||
readonly projectId: string;
|
||||
@@ -66,6 +67,7 @@ export interface RunRequest {
|
||||
* means no tools.
|
||||
*/
|
||||
readonly tools?: readonly string[] | undefined;
|
||||
readonly skills?: readonly RoleSkillEntry[] | undefined;
|
||||
readonly mcpServers?: Record<string, McpServerConfig> | undefined;
|
||||
readonly maxTurns?: number;
|
||||
readonly runId: string;
|
||||
@@ -135,6 +137,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
let sdkSessionId: string | undefined;
|
||||
let initializedSkillIds: readonly string[] | undefined;
|
||||
let error: string | undefined;
|
||||
let cleanupSecurity = async (): Promise<void> => {};
|
||||
try {
|
||||
await persistAgentMessage(req, "user", req.prompt);
|
||||
const toolConfig = claudeSdkToolConfigForRole(req.tools);
|
||||
@@ -143,17 +146,21 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
throw new Error("Agent run requires the configured workspace root");
|
||||
}
|
||||
const security = await createAgentSecurityPolicy({
|
||||
runId: req.runId,
|
||||
workspaceRoot,
|
||||
workspaceDir: req.project.workspaceDir,
|
||||
skills: req.skills,
|
||||
providerProxyEnv: req.providerProxyEnv,
|
||||
});
|
||||
cleanupSecurity = security.cleanup;
|
||||
const hasSkills = security.skillIds.length > 0;
|
||||
|
||||
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
|
||||
const options: QueryOptions = {
|
||||
cwd: security.cwd,
|
||||
// `skills` controls discovery/allowlisting, but an explicit `tools`
|
||||
// list still has to expose the Skill dispatcher itself.
|
||||
tools: [...toolConfig.tools, "Skill"],
|
||||
tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
|
||||
allowedTools: [...toolConfig.allowedTools],
|
||||
maxTurns: cap,
|
||||
includePartialMessages: true,
|
||||
@@ -172,7 +179,9 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
|
||||
settingSources: [],
|
||||
settings: { disableBundledSkills: true },
|
||||
plugins: [{ type: "local", path: security.skillPluginRoot, skipMcpDiscovery: true }],
|
||||
...(hasSkills && security.skillPluginRoot !== undefined
|
||||
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
|
||||
: {}),
|
||||
skills: [...security.skillIds],
|
||||
strictMcpConfig: true,
|
||||
// Claude Code 2.1.202 can honor the per-call opt-out despite
|
||||
@@ -316,6 +325,8 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
|
||||
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
|
||||
};
|
||||
} finally {
|
||||
await cleanupSecurity();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { validateCuratedSkillPlugin } from "./curatedSkills.js";
|
||||
import type { RoleSkillEntry } from "./models.js";
|
||||
import { prepareRunSkillPlugin, readSkillStoreRoot } from "./skillStore.js";
|
||||
|
||||
const PROVIDER_ENV_KEYS = new Set([
|
||||
"ANTHROPIC_BASE_URL",
|
||||
@@ -33,8 +34,10 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
|
||||
const MAX_AGENT_TMP_PREFIX_BYTES = 56;
|
||||
|
||||
export interface AgentSecurityInput {
|
||||
readonly runId: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly skills?: readonly RoleSkillEntry[] | undefined;
|
||||
/** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */
|
||||
readonly providerProxyEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
readonly hostEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
@@ -62,7 +65,8 @@ export interface AgentSecurityPolicy {
|
||||
readonly workspaceRoot: string;
|
||||
readonly env: Record<string, string | undefined>;
|
||||
readonly skillIds: readonly string[];
|
||||
readonly skillPluginRoot: string;
|
||||
readonly skillPluginRoot?: string | undefined;
|
||||
cleanup(): Promise<void>;
|
||||
readonly sandbox: AgentSandboxPolicy;
|
||||
}
|
||||
|
||||
@@ -82,7 +86,6 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
|
||||
const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]);
|
||||
assertShortAgentTemp(agentTmp);
|
||||
const skillPlugin = await validateCuratedSkillPlugin();
|
||||
|
||||
const path = hostEnv["PATH"]?.trim();
|
||||
if (path === undefined || path === "") {
|
||||
@@ -119,12 +122,21 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
|
||||
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
|
||||
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
|
||||
const selectedSkills = input.skills ?? [];
|
||||
const skillPlugin = selectedSkills.length === 0
|
||||
? null
|
||||
: await prepareRunSkillPlugin({
|
||||
storeRoot: readSkillStoreRoot(hostEnv),
|
||||
runId: input.runId,
|
||||
skills: selectedSkills,
|
||||
});
|
||||
return {
|
||||
cwd: workspaceDir,
|
||||
workspaceRoot,
|
||||
env,
|
||||
skillIds: skillPlugin.skillIds,
|
||||
skillPluginRoot: skillPlugin.root,
|
||||
skillIds: skillPlugin?.skillIds ?? [],
|
||||
...(skillPlugin !== null ? { skillPluginRoot: skillPlugin.root } : {}),
|
||||
cleanup: skillPlugin?.cleanup ?? (async () => {}),
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
@@ -140,7 +152,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
// workspace plus the named system runtime needed to execute tools.
|
||||
// SDK allowRead takes precedence over matching denyRead paths.
|
||||
denyRead: ["/"],
|
||||
allowRead: [workspaceDir, skillPlugin.root, ...runtimeReadPaths],
|
||||
allowRead: [workspaceDir, ...(skillPlugin !== null ? [skillPlugin.root] : []), ...runtimeReadPaths],
|
||||
},
|
||||
credentials: {
|
||||
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import type { RoleSkillEntry } from "./models.js";
|
||||
|
||||
const MAX_SKILL_FILES = 512;
|
||||
const MAX_SKILL_BYTES = 16 * 1024 * 1024;
|
||||
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const RUNTIME_PLUGIN_NAME = "cph-runtime";
|
||||
|
||||
export interface ImportedSkillContent {
|
||||
readonly name: string;
|
||||
readonly description: string | undefined;
|
||||
readonly contentDigest: string;
|
||||
}
|
||||
|
||||
export interface RunSkillPlugin {
|
||||
readonly root: string;
|
||||
readonly skillIds: readonly string[];
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function importSkillDirectory(input: {
|
||||
readonly sourceDir: string;
|
||||
readonly storeRoot: string;
|
||||
}): Promise<ImportedSkillContent> {
|
||||
const source = await inspectSkillDirectory(input.sourceDir);
|
||||
const versionsRoot = join(input.storeRoot, "versions");
|
||||
await mkdir(versionsRoot, { recursive: true, mode: 0o750 });
|
||||
const destination = join(versionsRoot, source.contentDigest);
|
||||
|
||||
try {
|
||||
const existing = await inspectSkillDirectory(destination);
|
||||
if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) {
|
||||
throw new Error(`stored skill content digest mismatch: ${source.name}`);
|
||||
}
|
||||
return source;
|
||||
} catch (error) {
|
||||
if (!isMissingPath(error)) throw error;
|
||||
}
|
||||
|
||||
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
|
||||
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);
|
||||
} catch (error) {
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
if (isDestinationExists(error)) {
|
||||
const existing = await inspectSkillDirectory(destination);
|
||||
if (existing.contentDigest === source.contentDigest && existing.name === source.name) return source;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function prepareRunSkillPlugin(input: {
|
||||
readonly storeRoot: string;
|
||||
readonly runId: string;
|
||||
readonly skills: readonly RoleSkillEntry[];
|
||||
}): Promise<RunSkillPlugin | null> {
|
||||
if (input.skills.length === 0) return null;
|
||||
const names = new Set<string>();
|
||||
for (const skill of input.skills) {
|
||||
requireSkillName(skill.name);
|
||||
if (!DIGEST_PATTERN.test(skill.contentDigest)) {
|
||||
throw new Error(`skill ${skill.name} has invalid content digest`);
|
||||
}
|
||||
if (names.has(skill.name)) throw new Error(`duplicate role skill: ${skill.name}`);
|
||||
names.add(skill.name);
|
||||
}
|
||||
|
||||
const runtimeRoot = join(input.storeRoot, "runtime");
|
||||
await mkdir(runtimeRoot, { recursive: true, mode: 0o750 });
|
||||
const pluginRoot = join(runtimeRoot, `run-${randomUUID()}`);
|
||||
try {
|
||||
await mkdir(join(pluginRoot, ".claude-plugin"), { recursive: true, mode: 0o750 });
|
||||
await mkdir(join(pluginRoot, "skills"), { recursive: true, mode: 0o750 });
|
||||
await writeFile(
|
||||
join(pluginRoot, ".claude-plugin", "plugin.json"),
|
||||
`${JSON.stringify({
|
||||
name: RUNTIME_PLUGIN_NAME,
|
||||
description: `Runtime skill snapshot for ${input.runId}`,
|
||||
version: "1",
|
||||
}, null, 2)}\n`,
|
||||
{ mode: 0o640 },
|
||||
);
|
||||
|
||||
for (const skill of input.skills) {
|
||||
const sourceDir = join(input.storeRoot, "versions", skill.contentDigest);
|
||||
const stored = await inspectSkillDirectory(sourceDir);
|
||||
if (stored.contentDigest !== skill.contentDigest) {
|
||||
throw new Error(`skill ${skill.name} content digest mismatch`);
|
||||
}
|
||||
if (stored.name !== skill.name) {
|
||||
throw new Error(`skill name mismatch: expected ${skill.name}, got ${stored.name}`);
|
||||
}
|
||||
await cp(sourceDir, join(pluginRoot, "skills", skill.name), {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await rm(pluginRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
root: pluginRoot,
|
||||
skillIds: input.skills.map((skill) => `${RUNTIME_PLUGIN_NAME}:${skill.name}`),
|
||||
async cleanup() {
|
||||
await rm(pluginRoot, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function readSkillStoreRoot(env: Readonly<Record<string, string | undefined>> = process.env): string {
|
||||
const configured = env["HUB_SKILL_STORE_ROOT"]?.trim();
|
||||
if (configured !== undefined && configured !== "") return resolve(configured);
|
||||
const stateRoot = env["XDG_STATE_HOME"]?.trim();
|
||||
if (stateRoot === undefined || stateRoot === "") {
|
||||
throw new Error("HUB_SKILL_STORE_ROOT or XDG_STATE_HOME is required");
|
||||
}
|
||||
return resolve(stateRoot, "skills");
|
||||
}
|
||||
|
||||
export async function verifyStoredSkill(input: {
|
||||
readonly storeRoot: string;
|
||||
readonly name: string;
|
||||
readonly contentDigest: string;
|
||||
}): Promise<void> {
|
||||
requireSkillName(input.name);
|
||||
if (!DIGEST_PATTERN.test(input.contentDigest)) {
|
||||
throw new Error(`skill ${input.name} has invalid content digest`);
|
||||
}
|
||||
const stored = await inspectSkillDirectory(join(input.storeRoot, "versions", input.contentDigest));
|
||||
if (stored.name !== input.name || stored.contentDigest !== input.contentDigest) {
|
||||
throw new Error(`stored skill verification failed: ${input.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectSkillDirectory(directory: string): Promise<ImportedSkillContent> {
|
||||
const root = resolve(directory);
|
||||
const rootStat = await lstat(root);
|
||||
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||
throw new Error(`skill root must be a real directory: ${root}`);
|
||||
}
|
||||
const files: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
|
||||
await walk(root, root, files);
|
||||
if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`);
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.bytes.byteLength, 0);
|
||||
if (totalBytes > MAX_SKILL_BYTES) throw new Error(`skill is too large: ${totalBytes} bytes`);
|
||||
const manifest = files.find((file) => file.path === "SKILL.md");
|
||||
if (manifest === undefined) throw new Error(`skill manifest missing: ${join(root, "SKILL.md")}`);
|
||||
const frontmatter = manifest.bytes.toString("utf8");
|
||||
const name = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim();
|
||||
if (name === undefined) throw new Error("skill manifest name missing");
|
||||
requireSkillName(name);
|
||||
const description = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim();
|
||||
|
||||
const hash = createHash("sha256");
|
||||
for (const file of files.sort((left, right) => left.path.localeCompare(right.path))) {
|
||||
hash.update(`${Buffer.byteLength(file.path)}:`);
|
||||
hash.update(file.path);
|
||||
hash.update(`${file.bytes.byteLength}:`);
|
||||
hash.update(file.bytes);
|
||||
}
|
||||
return { name, description, contentDigest: hash.digest("hex") };
|
||||
}
|
||||
|
||||
async function walk(
|
||||
root: string,
|
||||
directory: string,
|
||||
files: Array<{ readonly path: string; readonly bytes: Buffer }>,
|
||||
): Promise<void> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) throw new Error(`skill symlink is forbidden: ${fullPath}`);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(root, fullPath, files);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) throw new Error(`skill contains unsupported filesystem entry: ${fullPath}`);
|
||||
const relativePath = relative(root, fullPath);
|
||||
files.push({ path: relativePath, bytes: await readFile(fullPath) });
|
||||
if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireSkillName(name: string): void {
|
||||
if (!SKILL_NAME_PATTERN.test(name)) throw new Error(`invalid skill name: ${name}`);
|
||||
}
|
||||
|
||||
function isMissingPath(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
function isDestinationExists(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error &&
|
||||
(error.code === "EEXIST" || error.code === "ENOTEMPTY");
|
||||
}
|
||||
Reference in New Issue
Block a user