Compare commits

..

3 Commits

Author SHA1 Message Date
hongjr03 d36b00bbec feat: make agent roles and skills dynamic 2026-07-11 12:55:05 +08:00
hongjr03 17c0536958 fix: enable only curated agent skills 2026-07-11 12:25:52 +08:00
hongjr03 96e120e02c feat: add curated curriculum agent skills 2026-07-11 12:22:01 +08:00
30 changed files with 1443 additions and 19 deletions
+4
View File
@@ -28,6 +28,10 @@
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
控制面与 Docker adapter 后置(见 ADR-0025)。
- Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、
tools 与已安装 skillskill 版本进入 content-addressed 持久存储,run 只读加载所选快照。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。
## 纪律
@@ -37,6 +37,16 @@ that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
tool surfaces can differ even when the underlying model is the same; `/draft`
and `/review` must not resume the same Claude runtime cursor by accident.
Role definitions are Organization-scoped runtime data. A role bundle selects
its default model, system prompt, tool allowlist and installed Agent skill
versions. PostgreSQL is authoritative for role composition and skill metadata;
skill bytes live in a content-addressed persistent store selected only by the
recorded SHA-256 digest. Updating a role or binding skills takes effect without
a Hub release or process restart. A change to the role's execution surface
(model, prompt, tools, selected skill content) archives its active sessions so
the next run cannot resume a provider context created under stale instructions;
label and ordering-only changes preserve conversational continuity.
Environment variables:
```
ANTHROPIC_BASE_URL=https://openrouter.ai/api
@@ -122,6 +122,16 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
- `settingSources: []` and strict MCP configuration prevent an untrusted
workspace or service-user config from widening tools, hooks, MCP servers, or
sandbox paths.
- Agent skills are Organization-scoped runtime configuration, not Hub release
assets. A controlled host-console installer imports each version into a
content-addressed persistent store and records its digest in PostgreSQL. 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).
`bypassPermissions` is kept (headless server — no interactive prompts); the
+38 -2
View File
@@ -54,6 +54,34 @@ Default state paths are:
/var/cache/cph-hub/org-a
```
Organization Agent roles and skills are runtime configuration. Skill versions
are stored below the Silo state directory (`state/skills`) and are included in
`backup_silo.sh` as `agent-skills.tar`; PostgreSQL stores role bundles, skill
metadata and role-to-skill selection. Operate them as the Silo service user so
content ownership remains correct:
```sh
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh install-skill \
--organization org-a --source /staging/typst --version 1
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh upsert-role \
--organization org-a --role draft --label 草稿 --tools-json null
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh set-role-skills \
--organization org-a --role draft --skills outline,lesson-project,typst
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh list --organization org-a
```
`--tools-json null` means the full registered tool surface; `[]` means no
ordinary tools. SDK-bundled skills and workspace/user setting sources remain
disabled regardless of runtime configuration.
## Bootstrap the only Organization
Prepare a root-owned `0600` JSON file containing
@@ -129,16 +157,24 @@ sudo INSTANCE_ID=org-a \
bash hub/deploy/backup_silo.sh
```
The business set contains the PostgreSQL custom dump and workspace archive. The
The business set contains the PostgreSQL custom dump, workspace archive and
`agent-skills.tar`. The
separate recovery set contains the keyring and environment. Both include
checksums; neither destination may be the live host's only disk.
Restore into a separate drill database/workspace, verify checksums, then run:
Restore into a separate drill database, workspace and skill-store directory;
verify checksums before extracting both tar archives, then run:
```sh
set -a; . /path/to/restored/platform.env; set +a
mkdir -p "$HUB_PROJECT_WORKSPACE_ROOT" "$HUB_SKILL_STORE_ROOT"
tar -xf /path/to/business/workspaces.tar -C "$HUB_PROJECT_WORKSPACE_ROOT"
tar -xf /path/to/business/agent-skills.tar -C "$HUB_SKILL_STORE_ROOT"
node hub/dist/deployment/restore-preflight.js \
--keyring-file /path/to/restored/secret-keyring.json
sudo INSTANCE_ID=org-a ENV_FILE=/path/to/restored/platform.env \
HUB_DIR=/path/to/restored/release/hub \
bash hub/deploy/agent_config.sh verify-store --organization org-a
```
Traffic stays disabled until the sole Organization and every Feishu/provider
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
INSTANCE_ID="${INSTANCE_ID:?INSTANCE_ID required}"
ENV_FILE="${ENV_FILE:?ENV_FILE required}"
SERVICE_USER="${SERVICE_USER:-cph-$INSTANCE_ID}"
HUB_DIR="${HUB_DIR:-/srv/curriculum-project-hub/current/hub}"
[ "$(id -u)" -eq 0 ] || { echo "agent config console must run as root" >&2; exit 1; }
[ -r "$ENV_FILE" ] || { echo "environment file is not readable: $ENV_FILE" >&2; exit 1; }
[ -f "$HUB_DIR/dist/deployment/agent-config-cli.js" ] || { echo "Agent config CLI missing below $HUB_DIR" >&2; exit 1; }
id "$SERVICE_USER" >/dev/null 2>&1 || { echo "service user missing: $SERVICE_USER" >&2; exit 1; }
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
: "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}"
: "${HUB_SILO_ORGANIZATION_ID:?HUB_SILO_ORGANIZATION_ID missing from ENV_FILE}"
exec runuser --user "$SERVICE_USER" -- \
env -i \
DATABASE_URL="$DATABASE_URL" \
HUB_SILO_ORGANIZATION_ID="$HUB_SILO_ORGANIZATION_ID" \
HUB_SKILL_STORE_ROOT="${HUB_SKILL_STORE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/state/skills}" \
XDG_STATE_HOME="${XDG_STATE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/state}" \
PATH="${PATH:-/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin}" \
node "$HUB_DIR/dist/deployment/agent-config-cli.js" "$@"
+9 -1
View File
@@ -9,6 +9,7 @@ KEYRING_FILE="${KEYRING_FILE:?KEYRING_FILE required}"
BUSINESS_BACKUP_DIR="${BUSINESS_BACKUP_DIR:?BUSINESS_BACKUP_DIR required}"
RECOVERY_BACKUP_DIR="${RECOVERY_BACKUP_DIR:?RECOVERY_BACKUP_DIR required}"
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/state/skills}"
[ "$(id -u)" -eq 0 ] || { echo "backup must run as root" >&2; exit 1; }
umask 077
@@ -37,12 +38,14 @@ set +a
: "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}"
: "${HUB_PROJECT_WORKSPACE_ROOT:?HUB_PROJECT_WORKSPACE_ROOT missing from ENV_FILE}"
[ -d "$HUB_PROJECT_WORKSPACE_ROOT" ] || { echo "workspace root missing" >&2; exit 1; }
[ -d "$SKILL_STORE_ROOT" ] || { echo "skill store root missing" >&2; exit 1; }
install -d -o root -g root -m 0700 "$BUSINESS_BACKUP_DIR" "$RECOVERY_BACKUP_DIR"
business_root="$(realpath -m "$BUSINESS_BACKUP_DIR")"
recovery_root="$(realpath -m "$RECOVERY_BACKUP_DIR")"
workspace_root="$(realpath -m "$HUB_PROJECT_WORKSPACE_ROOT")"
secret_root="$(realpath -m "$(dirname "$KEYRING_FILE")")"
skill_root="$(realpath -m "$SKILL_STORE_ROOT")"
paths_overlap() {
local left="$1" right="$2"
[ "$left" = "$right" ] || [[ "$left/" == "$right/"* ]] || [[ "$right/" == "$left/"* ]]
@@ -58,6 +61,10 @@ for pair in \
exit 1
fi
done
if paths_overlap "$business_root" "$skill_root" || paths_overlap "$recovery_root" "$skill_root" || paths_overlap "$workspace_root" "$skill_root"; then
echo "backup destinations, workspace and skill store must not overlap: $skill_root" >&2
exit 1
fi
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
business="$business_root/$INSTANCE_ID-$stamp"
recovery="$recovery_root/$INSTANCE_ID-$stamp"
@@ -65,13 +72,14 @@ install -d -o root -g root -m 0700 "$business" "$recovery"
pg_dump --format=custom --file="$business/database.dump" "$DATABASE_URL"
tar --create --file="$business/workspaces.tar" --directory="$HUB_PROJECT_WORKSPACE_ROOT" .
tar --create --file="$business/agent-skills.tar" --directory="$SKILL_STORE_ROOT" .
cp --preserve=mode,ownership,timestamps "$KEYRING_FILE" "$recovery/secret-keyring.json"
cp --preserve=mode,ownership,timestamps "$ENV_FILE" "$recovery/platform.env"
chmod 0600 "$recovery/secret-keyring.json" "$recovery/platform.env"
(
cd "$business"
sha256sum database.dump workspaces.tar > SHA256SUMS
sha256sum database.dump workspaces.tar agent-skills.tar > SHA256SUMS
)
(
cd "$recovery"
+1
View File
@@ -18,6 +18,7 @@ EnvironmentFile=__ENV_FILE__
Environment=HOME=__SERVICE_HOME__
Environment=XDG_STATE_HOME=__STATE_DIR__
Environment=XDG_CACHE_HOME=__CACHE_DIR__
Environment=HUB_SKILL_STORE_ROOT=__SKILL_STORE_ROOT__
Environment=PATH=__RUNTIME_PATH__
# ADR-0024: the root-owned source remains unreadable by the service account;
# systemd materializes a read-only per-unit credential at runtime.
+5 -1
View File
@@ -23,6 +23,7 @@ SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}"
SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}"
SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-$STATE_DIR/skills}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:?WORKSPACE_ROOT required (use a short per-Silo path such as /w/997)}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:?PORT is required and must be unique on the host}"
@@ -105,6 +106,7 @@ for pair in \
"SERVICE_HOME:$SERVICE_HOME" \
"STATE_DIR:$STATE_DIR" \
"CACHE_DIR:$CACHE_DIR" \
"SKILL_STORE_ROOT:$SKILL_STORE_ROOT" \
"WORKSPACE_ROOT:$WORKSPACE_ROOT" \
"ENV_FILE:$ENV_FILE" \
"KEYRING_FILE:$KEYRING_FILE" \
@@ -281,6 +283,7 @@ provision_directory() {
provision_directory "$SERVICE_HOME"
provision_directory "$STATE_DIR"
provision_directory "$CACHE_DIR"
provision_directory "$SKILL_STORE_ROOT"
provision_directory "$WORKSPACE_ROOT"
# Resolve every provisioned path again and verify uid/gid/mode before writing
@@ -297,6 +300,7 @@ sed \
-e "s|__SERVICE_HOME__|$SERVICE_HOME|g" \
-e "s|__STATE_DIR__|$STATE_DIR|g" \
-e "s|__CACHE_DIR__|$CACHE_DIR|g" \
-e "s|__SKILL_STORE_ROOT__|$SKILL_STORE_ROOT|g" \
-e "s|__WORKSPACE_ROOT__|$WORKSPACE_ROOT|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \
@@ -315,5 +319,5 @@ install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT"
systemctl daemon-reload
systemctl enable "$SERVICE_UNIT"
echo "[install] installed $SERVICE_UNIT for $SERVICE_USER:$SERVICE_GROUP"
echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR workspaces=$WORKSPACE_ROOT"
echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR skills=$SKILL_STORE_ROOT workspaces=$WORKSPACE_ROOT"
echo "[install] start with: systemctl start $SERVICE_UNIT"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.7",
"version": "0.0.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.7",
"version": "0.0.10",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.7",
"version": "0.0.10",
"private": true,
"type": "module",
"engines": {
@@ -38,6 +38,7 @@
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma",
"secrets:rotate-kek": "node dist/deployment/rotate-secret-kek.js",
"agent-config": "node dist/deployment/agent-config-cli.js",
"silo:bootstrap": "node dist/deployment/bootstrap-silo-cli.js",
"silo:restore-preflight": "node dist/deployment/restore-preflight.js",
"deploy": "bash deploy/deploy_platform.sh",
@@ -0,0 +1,71 @@
-- ADR-0017: Organization-scoped runtime role bundles and content-addressed
-- skills. Composite foreign keys make cross-Organization role/skill bindings
-- structurally impossible.
CREATE TABLE "OrganizationAgentSkill" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"version" TEXT NOT NULL,
"description" TEXT,
"contentDigest" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"disabledAt" TIMESTAMP(3),
CONSTRAINT "OrganizationAgentSkill_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "OrganizationAgentRole" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"defaultModel" TEXT,
"systemPrompt" TEXT,
"tools" JSONB,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"disabledAt" TIMESTAMP(3),
CONSTRAINT "OrganizationAgentRole_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "OrganizationAgentRoleSkill" (
"organizationId" TEXT NOT NULL,
"agentRoleId" TEXT NOT NULL,
"agentSkillId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrganizationAgentRoleSkill_pkey" PRIMARY KEY ("organizationId", "agentRoleId", "agentSkillId")
);
CREATE UNIQUE INDEX "OrganizationAgentSkill_organizationId_name_key" ON "OrganizationAgentSkill"("organizationId", "name");
CREATE UNIQUE INDEX "OrganizationAgentSkill_organizationId_id_key" ON "OrganizationAgentSkill"("organizationId", "id");
CREATE INDEX "OrganizationAgentSkill_organizationId_disabledAt_idx" ON "OrganizationAgentSkill"("organizationId", "disabledAt");
CREATE INDEX "OrganizationAgentSkill_contentDigest_idx" ON "OrganizationAgentSkill"("contentDigest");
CREATE UNIQUE INDEX "OrganizationAgentRole_organizationId_roleId_key" ON "OrganizationAgentRole"("organizationId", "roleId");
CREATE UNIQUE INDEX "OrganizationAgentRole_organizationId_id_key" ON "OrganizationAgentRole"("organizationId", "id");
CREATE INDEX "OrganizationAgentRole_organizationId_disabledAt_sortOrder_idx" ON "OrganizationAgentRole"("organizationId", "disabledAt", "sortOrder");
CREATE INDEX "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortOrder_idx" ON "OrganizationAgentRoleSkill"("organizationId", "agentRoleId", "sortOrder");
CREATE INDEX "OrganizationAgentRoleSkill_organizationId_agentSkillId_idx" ON "OrganizationAgentRoleSkill"("organizationId", "agentSkillId");
ALTER TABLE "OrganizationAgentSkill" ADD CONSTRAINT "OrganizationAgentSkill_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRole" ADD CONSTRAINT "OrganizationAgentRole_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRoleSkill" ADD CONSTRAINT "OrganizationAgentRoleSkill_organizationId_agentRoleId_fkey"
FOREIGN KEY ("organizationId", "agentRoleId") REFERENCES "OrganizationAgentRole"("organizationId", "id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRoleSkill" ADD CONSTRAINT "OrganizationAgentRoleSkill_organizationId_agentSkillId_fkey"
FOREIGN KEY ("organizationId", "agentSkillId") REFERENCES "OrganizationAgentSkill"("organizationId", "id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Preserve current alpha behavior while moving role definitions into data.
INSERT INTO "OrganizationAgentRole" (
"id", "organizationId", "roleId", "label", "sortOrder", "updatedAt"
)
SELECT "id" || ':agent-role:draft', "id", 'draft', '草稿', 10, CURRENT_TIMESTAMP
FROM "Organization";
INSERT INTO "OrganizationAgentRole" (
"id", "organizationId", "roleId", "label", "sortOrder", "updatedAt"
)
SELECT "id" || ':agent-role:review', "id", 'review', '审校', 20, CURRENT_TIMESTAMP
FROM "Organization";
+66
View File
@@ -43,6 +43,8 @@ model Organization {
externalDirectoryConnections ExternalDirectoryConnection[]
providerConnections OrganizationProviderConnection[]
feishuApplicationConnection OrganizationFeishuApplicationConnection?
agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[]
auditEntries AuditEntry[] @relation("organizationAudit")
@@index([status])
@@ -78,6 +80,70 @@ enum OrganizationMemberRole {
MEMBER
}
/// Organization-scoped, content-addressed Agent skill registration. The DB is
/// the runtime registry; `contentDigest` selects an immutable directory below
/// the platform-controlled skill store and is never interpreted as a path.
model OrganizationAgentSkill {
id String @id @default(cuid())
organizationId String
name String
version String
description String?
contentDigest String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
roleBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, name])
@@unique([organizationId, id])
@@index([organizationId, disabledAt])
@@index([contentDigest])
}
/// ADR-0017 runtime role bundle. Roles are Organization-owned data rather than
/// a code enum: model, system prompt, tool allowlist and skill selection change
/// without a Hub release or process restart.
model OrganizationAgentRole {
id String @id @default(cuid())
organizationId String
roleId String
label String
defaultModel String?
systemPrompt String?
tools Json?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
skillBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, roleId])
@@unique([organizationId, id])
@@index([organizationId, disabledAt, sortOrder])
}
/// Same-Organization join enforced by both composite foreign keys. `sortOrder`
/// gives stable skill listing and prompt discovery order for a role bundle.
model OrganizationAgentRoleSkill {
organizationId String
agentRoleId String
agentSkillId String
sortOrder Int @default(0)
createdAt DateTime @default(now())
role OrganizationAgentRole @relation(fields: [organizationId, agentRoleId], references: [organizationId, id], onDelete: Cascade)
skill OrganizationAgentSkill @relation(fields: [organizationId, agentSkillId], references: [organizationId, id], onDelete: Cascade)
@@id([organizationId, agentRoleId, agentSkillId])
@@index([organizationId, agentRoleId, sortOrder])
@@index([organizationId, agentSkillId])
}
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings {
+260
View File
@@ -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;
}
+8
View File
@@ -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. */
+29 -2
View File
@@ -29,10 +29,11 @@
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant.
*/
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
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;
@@ -91,6 +93,8 @@ export interface RunResult {
readonly costUsd?: number | undefined;
readonly numTurns: number;
readonly sdkSessionId?: string | undefined;
/** Skill ids reported by the SDK init event, not merely requested options. */
readonly initializedSkillIds?: readonly string[] | undefined;
readonly error?: string;
}
@@ -131,7 +135,9 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
let costUsd: number | undefined;
let numTurns = 0;
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);
@@ -140,15 +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,
tools: [...toolConfig.tools],
// `skills` controls discovery/allowlisting, but an explicit `tools`
// list still has to expose the Skill dispatcher itself.
tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
allowedTools: [...toolConfig.allowedTools],
maxTurns: cap,
includePartialMessages: true,
@@ -166,6 +178,11 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
// The project workspace is untrusted input. Do not load user/project
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [],
settings: { disableBundledSkills: 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
// sandbox.allowUnsandboxedCommands=false. Enforce the invariant again at
@@ -191,6 +208,12 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
for await (const message of conversation) {
switch (message.type) {
case "system": {
if (message.subtype === "init") {
initializedSkillIds = [...(message as SDKSystemMessage).skills];
}
break;
}
case "stream_event": {
const evt = (message as SDKPartialAssistantMessage).event;
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
@@ -287,6 +310,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(costUsd !== undefined ? { costUsd } : {}),
numTurns,
sdkSessionId,
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
...(error !== undefined ? { error } : {}),
};
} catch (e) {
@@ -298,8 +322,11 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(costUsd !== undefined ? { costUsd } : {}),
numTurns,
sdkSessionId,
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
};
} finally {
await cleanupSecurity();
}
}
+19 -1
View File
@@ -1,6 +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 type { RoleSkillEntry } from "./models.js";
import { prepareRunSkillPlugin, readSkillStoreRoot } from "./skillStore.js";
const PROVIDER_ENV_KEYS = new Set([
"ANTHROPIC_BASE_URL",
@@ -32,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;
@@ -60,6 +64,9 @@ export interface AgentSecurityPolicy {
readonly cwd: string;
readonly workspaceRoot: string;
readonly env: Record<string, string | undefined>;
readonly skillIds: readonly string[];
readonly skillPluginRoot?: string | undefined;
cleanup(): Promise<void>;
readonly sandbox: AgentSandboxPolicy;
}
@@ -115,10 +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 ?? [],
...(skillPlugin !== null ? { skillPluginRoot: skillPlugin.root } : {}),
cleanup: skillPlugin?.cleanup ?? (async () => {}),
sandbox: {
enabled: true,
failIfUnavailable: true,
@@ -134,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, ...runtimeReadPaths],
allowRead: [workspaceDir, ...(skillPlugin !== null ? [skillPlugin.root] : []), ...runtimeReadPaths],
},
credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
+217
View File
@@ -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");
}
+157
View File
@@ -0,0 +1,157 @@
import { readFile } from "node:fs/promises";
import { prisma } from "../db.js";
import { OrganizationAgentConfiguration } from "../agent/configuration.js";
import { readSkillStoreRoot, verifyStoredSkill } from "../agent/skillStore.js";
import { readSiloOrganizationId } from "./silo.js";
async function main(argv: readonly string[]): Promise<void> {
const [command, ...args] = argv;
if (command === undefined || command === "help" || command === "--help") {
printHelp();
return;
}
const options = parseOptions(args);
const organizationId = required(options, "organization");
const siloOrganizationId = readSiloOrganizationId();
if (organizationId !== siloOrganizationId) {
throw new Error(`Silo Agent configuration is restricted to ${siloOrganizationId}`);
}
const configuration = new OrganizationAgentConfiguration(prisma, readSkillStoreRoot());
switch (command) {
case "install-skill": {
const installed = await configuration.installSkill({
organizationId,
sourceDir: required(options, "source"),
version: required(options, "version"),
});
console.log(JSON.stringify(installed));
return;
}
case "upsert-role": {
const systemPromptFile = options.get("system-prompt-file");
const toolsJson = options.get("tools-json");
const tools = toolsJson === undefined
? undefined
: toolsJson === "null"
? null
: parseTools(toolsJson);
const sortOrderRaw = options.get("sort-order");
const role = await configuration.upsertRole({
organizationId,
roleId: required(options, "role"),
label: required(options, "label"),
...(options.has("model") ? { defaultModel: options.get("model") ?? null } : {}),
...(systemPromptFile !== undefined
? { systemPrompt: await readFile(systemPromptFile, "utf8") }
: {}),
...(tools !== undefined ? { tools } : {}),
...(sortOrderRaw !== undefined ? { sortOrder: integer(sortOrderRaw, "sort-order") } : {}),
});
console.log(JSON.stringify(role));
return;
}
case "set-role-skills": {
const skills = required(options, "skills").split(",").map((name) => name.trim()).filter(Boolean);
await configuration.setRoleSkills({
organizationId,
roleId: required(options, "role"),
skillNames: skills,
});
console.log(JSON.stringify({ roleId: required(options, "role"), skills }));
return;
}
case "list": {
const roles = await prisma.organizationAgentRole.findMany({
where: { organizationId },
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
include: {
skillBindings: {
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
include: { skill: { select: { name: true, version: true, disabledAt: true } } },
},
},
});
console.log(JSON.stringify(roles.map((role) => ({
roleId: role.roleId,
label: role.label,
defaultModel: role.defaultModel,
systemPromptConfigured: role.systemPrompt !== null,
tools: role.tools,
disabled: role.disabledAt !== null,
skills: role.skillBindings.map((binding) => ({
name: binding.skill.name,
version: binding.skill.version,
disabled: binding.skill.disabledAt !== null,
})),
})), null, 2));
return;
}
case "verify-store": {
const skills = await prisma.organizationAgentSkill.findMany({
where: { organizationId, disabledAt: null },
select: { name: true, contentDigest: true },
});
for (const skill of skills) {
await verifyStoredSkill({ storeRoot: readSkillStoreRoot(), ...skill });
}
console.log(JSON.stringify({ verifiedSkills: skills.length }));
return;
}
default:
throw new Error(`unknown Agent configuration command: ${command}`);
}
}
function parseOptions(args: readonly string[]): Map<string, string> {
const options = new Map<string, string>();
for (let index = 0; index < args.length; index += 2) {
const flag = args[index];
const value = args[index + 1];
if (flag === undefined || !flag.startsWith("--") || value === undefined) {
throw new Error(`expected --name value, got: ${args.slice(index).join(" ")}`);
}
const name = flag.slice(2);
if (options.has(name)) throw new Error(`duplicate option: --${name}`);
options.set(name, value);
}
return options;
}
function required(options: ReadonlyMap<string, string>, name: string): string {
const value = options.get(name)?.trim();
if (value === undefined || value === "") throw new Error(`--${name} is required`);
return value;
}
function parseTools(raw: string): readonly string[] {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== "string")) {
throw new Error("--tools-json must be null or a JSON string array");
}
return parsed;
}
function integer(raw: string, name: string): number {
const value = Number(raw);
if (!Number.isSafeInteger(value)) throw new Error(`--${name} must be an integer`);
return value;
}
function printHelp(): void {
console.log(`Usage:
agent-config install-skill --organization ORG --source DIR --version VERSION
agent-config upsert-role --organization ORG --role ID --label LABEL [--model MODEL] [--system-prompt-file FILE] [--tools-json JSON] [--sort-order N]
agent-config set-role-skills --organization ORG --role ID --skills name,name
agent-config list --organization ORG
agent-config verify-store --organization ORG`);
}
main(process.argv.slice(2))
.catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+16
View File
@@ -226,6 +226,22 @@ async function initializeSilo(
const count = await tx.organization.count();
if (count !== 0) throw new Error(`Silo bootstrap requires an empty Organization set; found ${count}`);
await tx.organization.create({ data: { ...input.organization } });
await tx.organizationAgentRole.createMany({
data: [
{
organizationId: input.organization.id,
roleId: "draft",
label: "草稿",
sortOrder: 10,
},
{
organizationId: input.organization.id,
roleId: "review",
label: "审校",
sortOrder: 20,
},
],
});
await tx.organizationProjectSettings.create({
data: { organizationId: input.organization.id, membersCanCreateProjects: true },
});
+11 -1
View File
@@ -391,10 +391,20 @@ function formatRoleSlashCommandHelp(role: RoleEntry): string {
if (role.defaultModel !== undefined) {
lines.push(`- 默认模型: ${role.defaultModel}`);
}
lines.push(`- 工具范围: ${roleToolsDescription(role)}`, "", `帮助: /help ${role.id} 或 /${role.id} help`);
lines.push(
`- 工具范围: ${roleToolsDescription(role)}`,
`- Skills: ${roleSkillsDescription(role)}`,
"",
`帮助: /help ${role.id} 或 /${role.id} help`,
);
return lines.join("\n");
}
function roleSkillsDescription(role: RoleEntry): string {
if (role.skills === undefined || role.skills.length === 0) return "无";
return role.skills.map((skill) => `${skill.name}@${skill.version}`).join(", ");
}
function roleToolsDescription(role: RoleEntry): string {
if (role.tools === undefined) return "全部已注册工具";
if (role.tools.length === 0) return "无";
+11 -1
View File
@@ -405,6 +405,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
metadata: {
roleId,
model,
requestedSkills: (role?.skills ?? []).map((skill) => ({
name: skill.name,
version: skill.version,
contentDigest: skill.contentDigest,
})),
prompt: agentPrompt.slice(0, 200),
sender: senderMetadata,
feishuTriggerContext,
@@ -480,6 +485,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
providerProxyEnv: { ...providerLease.sdkEnv },
resumeSessionId: sessionMetadata.claudeSessionId,
tools: roleTools,
skills: role?.skills,
mcpServers: { cph_hub: fileDeliveryMcpServer },
maxTurns: runPolicy.maxTurns,
runId: run.id,
@@ -568,7 +574,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
runId: run.id,
projectId,
action: "run.finished",
metadata: { status: result.status, deliveredFiles },
metadata: {
status: result.status,
deliveredFiles,
initializedSkills: [...(result.initializedSkillIds ?? [])],
},
});
await removeProcessingReaction();
})
+87 -3
View File
@@ -1,5 +1,5 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js";
import { InMemoryModelRegistry, type ModelRegistry, type RoleEntry, type RoleSkillEntry } from "../agent/models.js";
import { lockActiveOrganization } from "../org/status.js";
import { decryptStoredProviderCredential } from "../connections/providerConnections.js";
import { openProviderProxyLease, type AgentProviderLease, type ProviderUpstreamCredential } from "../connections/providerProxy.js";
@@ -141,8 +141,75 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
};
}
modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
return this.envSettings.modelRegistry(scope);
async modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
const projectId = scope?.projectId?.trim();
if (projectId === undefined || projectId === "") {
throw new Error("projectId is required to resolve Agent runtime configuration");
}
const project = await this.prisma.project.findUnique({
where: { id: projectId },
select: {
archivedAt: true,
organization: {
select: {
id: true,
status: true,
agentRoles: {
where: { disabledAt: null },
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
include: {
skillBindings: {
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
include: { skill: true },
},
},
},
},
},
},
});
if (project === null || project.archivedAt !== null) {
throw new Error(`active project not found: ${projectId}`);
}
if (project.organization.status !== "ACTIVE") {
throw new Error(`organization ${project.organization.id} is ${project.organization.status}`);
}
if (project.organization.agentRoles.length === 0) {
throw new Error(`no active Agent roles configured for organization ${project.organization.id}`);
}
const defaults = await this.envSettings.modelRegistry(scope);
const enabledModels = new Set(defaults.listModels().map((model) => model.id));
const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({
id: role.roleId,
label: role.label,
defaultModel: validateRoleModel(role.roleId, role.defaultModel, enabledModels),
...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}),
...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}),
skills: role.skillBindings.map((binding): RoleSkillEntry => {
if (binding.skill.disabledAt !== null) {
throw new Error(`role ${role.roleId} selects disabled skill ${binding.skill.name}`);
}
if (!/^[a-f0-9]{64}$/.test(binding.skill.contentDigest)) {
throw new Error(`skill ${binding.skill.name} has invalid content digest`);
}
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(binding.skill.name)) {
throw new Error(`skill has invalid name: ${binding.skill.name}`);
}
if (binding.skill.version.trim() === "") {
throw new Error(`skill ${binding.skill.name} has empty version`);
}
return {
name: binding.skill.name,
version: binding.skill.version,
contentDigest: binding.skill.contentDigest,
};
}),
}));
if (!roles.some((role) => role.id === "draft")) {
throw new Error(`default Agent role draft is not configured for organization ${project.organization.id}`);
}
return new InMemoryModelRegistry(defaults.listModels(), roles);
}
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
@@ -150,6 +217,23 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
}
}
function roleToolsFromJson(roleId: string, value: Prisma.JsonValue): readonly string[] {
if (!Array.isArray(value) || value.some((tool) => typeof tool !== "string")) {
throw new Error(`role ${roleId} tools must be a JSON string array`);
}
return value as string[];
}
function validateRoleModel(
roleId: string,
model: string | null,
enabledModels: ReadonlySet<string>,
): string | undefined {
if (model === null) return undefined;
if (!enabledModels.has(model)) throw new Error(`role ${roleId} selects unavailable model ${model}`);
return model;
}
async function loadActiveProviderSecret(
tx: Prisma.TransactionClient,
projectId: string,
@@ -0,0 +1,90 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { OrganizationAgentConfiguration } from "../../src/agent/configuration.js";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
describe("Organization Agent configuration management", () => {
let root: string;
let configuration: OrganizationAgentConfiguration;
beforeEach(async () => {
await resetDb();
root = await mkdtemp(join(tmpdir(), "cph-agent-config-"));
configuration = new OrganizationAgentConfiguration(prisma, join(root, "store"));
});
afterAll(async () => {
await prisma.$disconnect();
});
it("installs versioned skills and selects them as part of a dynamic role bundle", async () => {
const typst = await makeSkill(root, "typst");
const outline = await makeSkill(root, "outline");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "0.15.0" });
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: outline, version: "1" });
await configuration.upsertRole({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
label: "课程草稿",
defaultModel: "anthropic/claude-sonnet-5",
systemPrompt: "write carefully",
tools: ["read_file", "write_file", "cph_build"],
sortOrder: 10,
});
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
await prisma.agentSession.create({
data: {
id: "session-old-role-config",
projectId: "project-a",
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
},
});
await configuration.setRoleSkills({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
skillNames: ["outline", "typst"],
});
const role = await prisma.organizationAgentRole.findUniqueOrThrow({
where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } },
include: { skillBindings: { orderBy: { sortOrder: "asc" }, include: { skill: true } } },
});
expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" });
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }))
.resolves.toMatchObject({ archivedAt: expect.any(Date) });
});
it("rejects unknown, disabled and cross-Organization skills", async () => {
await seedTestOrganization("org_other", "other");
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: "org_other", sourceDir: typst, version: "1" });
await configuration.upsertRole({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
label: "Draft",
tools: [],
});
await expect(configuration.setRoleSkills({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
skillNames: ["typst"],
})).rejects.toThrow("active skills not found in organization");
});
async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name);
await mkdir(source, { recursive: true });
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\ndescription: ${name} skill\n---\n# ${name}\n`);
return source;
}
});
@@ -0,0 +1,121 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { DatabaseRuntimeSettings } from "../../src/settings/runtime.js";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
describe("Organization-scoped Agent runtime configuration", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("resolves role prompt, model, tools and skills from the project Organization", async () => {
await seedTestOrganization("org_other", "other");
await Promise.all([
prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
}),
prisma.project.create({
data: { id: "project-b", organizationId: "org_other", name: "B", workspaceDir: "/tmp/b" },
}),
]);
const [skillA, skillB] = await Promise.all([
prisma.organizationAgentSkill.create({
data: {
id: "skill-a",
organizationId: DEFAULT_ORG_ID,
name: "typst",
version: "0.15.0",
contentDigest: "a".repeat(64),
},
}),
prisma.organizationAgentSkill.create({
data: {
id: "skill-b",
organizationId: "org_other",
name: "typst",
version: "other",
contentDigest: "b".repeat(64),
},
}),
]);
const [roleA, roleB] = await Promise.all([
prisma.organizationAgentRole.create({
data: {
id: "role-a",
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
label: "A Draft",
defaultModel: "anthropic/claude-sonnet-5",
systemPrompt: "prompt-a",
tools: ["read_file", "cph_build"],
},
}),
prisma.organizationAgentRole.create({
data: {
id: "role-b",
organizationId: "org_other",
roleId: "draft",
label: "B Draft",
systemPrompt: "prompt-b",
tools: [],
},
}),
]);
await Promise.all([
prisma.organizationAgentRoleSkill.create({
data: { organizationId: DEFAULT_ORG_ID, agentRoleId: roleA.id, agentSkillId: skillA.id },
}),
prisma.organizationAgentRoleSkill.create({
data: { organizationId: "org_other", agentRoleId: roleB.id, agentSkillId: skillB.id },
}),
]);
const settings = new DatabaseRuntimeSettings(prisma, testSecretEnvelope, {});
const registryA = await settings.modelRegistry({ projectId: "project-a" });
const registryB = await settings.modelRegistry({ projectId: "project-b" });
expect(registryA.role("draft")).toMatchObject({
label: "A Draft",
systemPrompt: "prompt-a",
tools: ["read_file", "cph_build"],
skills: [{ name: "typst", version: "0.15.0", contentDigest: "a".repeat(64) }],
});
expect(registryB.role("draft")).toMatchObject({
label: "B Draft",
systemPrompt: "prompt-b",
tools: [],
skills: [{ name: "typst", version: "other", contentDigest: "b".repeat(64) }],
});
});
it("fails closed for missing scope and disabled role skills", async () => {
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
const skill = await prisma.organizationAgentSkill.create({
data: {
id: "skill-disabled",
organizationId: DEFAULT_ORG_ID,
name: "typst",
version: "0.15.0",
contentDigest: "c".repeat(64),
disabledAt: new Date(),
},
});
const role = await prisma.organizationAgentRole.create({
data: { id: "role-a", organizationId: DEFAULT_ORG_ID, roleId: "draft", label: "Draft" },
});
await prisma.organizationAgentRoleSkill.create({
data: { organizationId: DEFAULT_ORG_ID, agentRoleId: role.id, agentSkillId: skill.id },
});
const settings = new DatabaseRuntimeSettings(prisma, testSecretEnvelope, {});
await expect(settings.modelRegistry()).rejects.toThrow("projectId is required");
await expect(settings.modelRegistry({ projectId: "project-a" })).rejects.toThrow(
"role draft selects disabled skill typst",
);
});
});
@@ -7,6 +7,7 @@ import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { runAgent, type StreamEvent } from "../../src/agent/runner.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
const execFileAsync = promisify(execFile);
const originalEnv = new Map<string, string | undefined>();
@@ -52,7 +53,9 @@ describe("real Claude SDK sandbox boundary", () => {
const workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`);
const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`);
const serviceSecret = join(root, `s_${nonce.slice(16, 24)}`);
roots.push(workspace, sibling, serviceSecret);
const skillSource = join(root, `k_${nonce.slice(24, 28)}`);
const skillStore = join(root, `ks_${nonce.slice(28, 32)}`);
roots.push(workspace, sibling, serviceSecret, skillSource, skillStore);
await Promise.all([
mkdir(workspace, { recursive: true }),
mkdir(sibling, { recursive: true }),
@@ -62,6 +65,12 @@ describe("real Claude SDK sandbox boundary", () => {
writeFile(join(sibling, "secret.txt"), "sibling-secret\n"),
writeFile(serviceSecret, "platform-secret\n"),
]);
await mkdir(skillSource, { recursive: true });
await writeFile(join(skillSource, "SKILL.md"), "---\nname: outline\ndescription: Outline\n---\n");
const installedSkill = await importSkillDirectory({ sourceDir: skillSource, storeRoot: skillStore });
const untrustedSkill = join(workspace, ".claude", "skills", "untrusted");
await mkdir(untrustedSkill, { recursive: true });
await writeFile(join(untrustedSkill, "SKILL.md"), "---\nname: untrusted\ndescription: must never load\n---\n");
// macOS tmpdir is reached through /var -> /private/var. Exercise the
// sandbox with canonical paths, matching the canonical cwd returned by
// createAgentSecurityPolicy rather than relying on a host symlink alias.
@@ -83,6 +92,7 @@ describe("real Claude SDK sandbox boundary", () => {
DATABASE_URL: "postgresql://platform-secret",
FEISHU_APP_SECRET: "feishu-secret",
HUB_SESSION_SECRET: "session-secret",
HUB_SKILL_STORE_ROOT: skillStore,
});
const bashCommand = [
@@ -130,6 +140,7 @@ describe("real Claude SDK sandbox boundary", () => {
ANTHROPIC_API_KEY: "",
},
tools: ["bash"],
skills: [{ name: "outline", version: "1", contentDigest: installedSkill.contentDigest }],
maxTurns: 3,
runId: "sandbox-run",
sessionId: "sandbox-session",
@@ -146,6 +157,9 @@ describe("real Claude SDK sandbox boundary", () => {
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
).toBe("completed");
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
"cph-runtime:outline",
]));
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
expect(toolResults).toHaveLength(2);
const rejectedOptOut = toolResults[0];
+3
View File
@@ -35,6 +35,9 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
const tables = [
"OrganizationAgentRoleSkill",
"OrganizationAgentRole",
"OrganizationAgentSkill",
"FeishuEventReceipt",
"FeishuUserIdentity",
"FeishuApplicationCredentialVersion",
@@ -53,6 +53,14 @@ describe("Alpha Silo bootstrap", () => {
expect(await prisma.team.count({ where: { slug: "teachers", archivedAt: null } })).toBe(1);
expect(await prisma.teamMembership.count({ where: { revokedAt: null } })).toBe(1);
expect(await prisma.organizationProviderConnection.count({ where: { status: "ACTIVE" } })).toBe(1);
await expect(prisma.organizationAgentRole.findMany({
where: { organizationId: "org_alpha", disabledAt: null },
orderBy: { sortOrder: "asc" },
select: { roleId: true, label: true },
})).resolves.toEqual([
{ roleId: "draft", label: "草稿" },
{ roleId: "review", label: "审校" },
]);
const persisted = JSON.stringify({
feishu: await prisma.feishuApplicationCredentialVersion.findMany(),
+8
View File
@@ -14,6 +14,7 @@ describe("agent subprocess security policy", () => {
it("passes only the run proxy capability and safe runtime variables and protects the capability from tools", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
providerProxyEnv: {
@@ -51,6 +52,8 @@ describe("agent subprocess security policy", () => {
expect(policy.env.TEMP).toBe(policy.env.TMPDIR);
expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t"));
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56);
expect(policy.skillIds).toEqual([]);
expect(policy.skillPluginRoot).toBeUndefined();
expect(policy.sandbox).toMatchObject({
enabled: true,
@@ -75,6 +78,7 @@ describe("agent subprocess security policy", () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
providerProxyEnv: {
@@ -88,6 +92,7 @@ describe("agent subprocess security policy", () => {
it("keeps every SDK temp variable on a short path inside the project workspace", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: { PATH: "/usr/bin:/bin" },
@@ -113,6 +118,7 @@ describe("agent subprocess security policy", () => {
await mkdir(workspace, { recursive: true });
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: { PATH: "/usr/bin:/bin" },
@@ -127,6 +133,7 @@ describe("agent subprocess security policy", () => {
await symlink(outside, linked);
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: linked,
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
@@ -142,6 +149,7 @@ describe("agent subprocess security policy", () => {
await symlink(sibling, linked);
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: linked,
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
+66 -3
View File
@@ -1,8 +1,9 @@
import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runAgent } from "../../src/agent/runner.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
const queryMock = vi.hoisted(() => vi.fn());
@@ -33,6 +34,16 @@ function resultMessage(sessionId: string, costUsd?: number) {
};
}
function initMessage(skills: string[]) {
return {
type: "system",
subtype: "init",
skills,
tools: [],
plugins: [],
};
}
function messages(...items: unknown[]) {
return (async function* () {
for (const item of items) yield item;
@@ -66,7 +77,7 @@ describe("runAgent", () => {
workspaceRoot = await realpath(workspaceRoot);
workspace = await realpath(workspace);
previousSecrets = Object.fromEntries(
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET"].map((name) => [name, process.env[name]]),
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET", "HUB_SKILL_STORE_ROOT"].map((name) => [name, process.env[name]]),
);
});
@@ -102,6 +113,8 @@ describe("runAgent", () => {
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
settingSources: [],
settings: { disableBundledSkills: true },
skills: [],
strictMcpConfig: true,
sandbox: expect.objectContaining({
enabled: true,
@@ -134,6 +147,26 @@ describe("runAgent", () => {
expect(call?.options).not.toHaveProperty("resume");
});
it("returns the skills actually reported by SDK initialization", async () => {
queryMock.mockReturnValue(messages(
initMessage(["cph-runtime:outline"]),
assistantMessage("fresh"),
resultMessage("sdk-session-1"),
));
const result = await runAgent({
prompt: "写一个大纲",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(result.initializedSkillIds).toEqual(["cph-runtime:outline"]);
});
it("maps role tool ids to the Claude SDK tool whitelist", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
@@ -156,7 +189,7 @@ describe("runAgent", () => {
});
});
it("disables all SDK tools for an empty role tool whitelist", async () => {
it("disables SDK tools for an empty role tool and skill selection", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
@@ -178,6 +211,36 @@ describe("runAgent", () => {
});
});
it("loads only the dynamic skills selected by the role", async () => {
const source = join(root, "skill-source");
const storeRoot = join(root, "skill-store");
await mkdir(source);
await writeFile(join(source, "SKILL.md"), "---\nname: typst\ndescription: Typst\n---\n");
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
process.env["HUB_SKILL_STORE_ROOT"] = storeRoot;
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "排版",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
tools: [],
skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }],
runId: "run-skill",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Skill"],
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-runtime:typst"],
},
});
});
it("returns SDK-reported cost when present", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042)));
+71
View File
@@ -0,0 +1,71 @@
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { importSkillDirectory, prepareRunSkillPlugin } from "../../src/agent/skillStore.js";
describe("content-addressed Agent skill store", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("imports a skill into an immutable digest directory and materializes a selected run plugin", async () => {
const root = await makeRoot();
const source = await makeSkill(root, "typst", "Typst help");
const storeRoot = join(root, "store");
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
expect(installed).toMatchObject({ name: "typst", description: "Typst help" });
expect(installed.contentDigest).toMatch(/^[a-f0-9]{64}$/);
await expect(readFile(join(storeRoot, "versions", installed.contentDigest, "SKILL.md"), "utf8"))
.resolves.toContain("name: typst");
const plugin = await prepareRunSkillPlugin({
storeRoot,
runId: "run-1",
skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }],
});
expect(plugin).not.toBeNull();
expect(plugin?.skillIds).toEqual(["cph-runtime:typst"]);
await expect(readFile(join(plugin!.root, "skills", "typst", "reference.md"), "utf8"))
.resolves.toBe("reference\n");
await plugin?.cleanup();
await expect(readFile(join(plugin!.root, ".claude-plugin", "plugin.json"), "utf8"))
.rejects.toMatchObject({ code: "ENOENT" });
});
it("rejects symlinks and detects content tampering before a run", async () => {
const root = await makeRoot();
const source = await makeSkill(root, "outline", "Outline");
await symlink(join(source, "reference.md"), join(source, "link.md"));
await expect(importSkillDirectory({ sourceDir: source, storeRoot: join(root, "store") }))
.rejects.toThrow(/symlink/);
await rm(join(source, "link.md"));
const storeRoot = join(root, "store");
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
await writeFile(join(storeRoot, "versions", installed.contentDigest, "reference.md"), "tampered\n");
await expect(prepareRunSkillPlugin({
storeRoot,
runId: "run-2",
skills: [{ name: "outline", version: "1", contentDigest: installed.contentDigest }],
})).rejects.toThrow(/content digest mismatch/);
});
async function makeRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-skill-store-"));
roots.push(root);
return root;
}
});
async function makeSkill(root: string, name: string, description: string): Promise<string> {
const source = join(root, "source", name);
await mkdir(source, { recursive: true });
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n# ${name}\n`);
await writeFile(join(source, "reference.md"), "reference\n");
return source;
}