diff --git a/.github/workflows/riscv64-native.yml b/.github/workflows/riscv64-native.yml new file mode 100644 index 000000000..c3cd7cfe1 --- /dev/null +++ b/.github/workflows/riscv64-native.yml @@ -0,0 +1,227 @@ +name: riscv64-native + +# Real-riscv64 CI on the free Cloud-V / 10xEngineers board runners +# (https://10x-engineers.github.io/riscv-ci-partners/). Each job provisions a +# fresh container on a physical board. Guarded to the fork on purpose: +# iflytek/skillhub has no such runner, an unguarded job would queue forever. +# +# authoring-on-riscv64 — jar boot, scripts run natively (every push) +# authoring-docker-mode-on-riscv64 — jar boot with execution-mode=docker: +# validation scripts run inside the +# alpine:3.20 riscv64 container (every push) +# backend-tests-on-riscv64 — full ./mvnw test suite on a Banana Pi F3 +# (manual dispatch only: multi-hour, and a +# different board type so it never blocks +# the quick verification jobs) +on: + workflow_dispatch: + push: + branches: [feat/skill-authoring-validation] + +jobs: + authoring-on-riscv64: + if: github.repository == 'zjncs/skillhub' + runs-on: [self-hosted, visionfive2] + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + + - name: Record the board (native-proof evidence) + run: | + uname -a + head -5 /proc/cpuinfo + if grep -qi qemu /proc/cpuinfo; then + echo "WARNING: qemu markers in cpuinfo — this board looks emulated" + else + echo "OK: no qemu markers in cpuinfo" + fi + + - name: Install JDK 21, PostgreSQL, Redis + run: | + sudo apt-get update + sudo apt-get install -y postgresql redis-server curl unzip + if ! sudo apt-get install -y openjdk-21-jdk-headless; then + echo "apt JDK 21 unavailable — falling back to the Temurin riscv64 tarball" + curl -fsSL "https://api.adoptium.net/v3/binary/latest/21/ga/linux/riscv64/jdk/hotspot/normal/eclipse" -o /tmp/jdk21.tgz + mkdir -p "$HOME/jdk21" + tar -xzf /tmp/jdk21.tgz -C "$HOME/jdk21" --strip-components=1 + echo "$HOME/jdk21/bin" >> "$GITHUB_PATH" + "$HOME/jdk21/bin/java" -version + else + java -version + fi + + - name: Start services and create the empty database + run: | + sudo service postgresql start + sudo service redis-server start + sudo -u postgres psql -v ON_ERROR_STOP=1 <<'SQL' + DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'skillhub') THEN + CREATE ROLE skillhub LOGIN PASSWORD 'skillhub_dev'; + END IF; + END $$; + SQL + sudo -u postgres psql -c "DROP DATABASE IF EXISTS skillhub_riscv_ci;" + sudo -u postgres psql -c "CREATE DATABASE skillhub_riscv_ci OWNER skillhub;" + + - name: Native riscv64 build (Maven on the board) + run: | + cd server + ./mvnw -B package -pl skillhub-app -am -DskipTests + + - name: Boot the server and drive the authoring flow + run: | + SKILLHUB_RISCV_BOOT_MODE=jar SKILLHUB_RISCV_DB=skillhub_riscv_ci \ + bash scripts/riscv64-verify.sh + + authoring-docker-mode-on-riscv64: + if: github.repository == 'zjncs/skillhub' + runs-on: [self-hosted, visionfive2] + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + + - name: Record the board (native-proof evidence) + run: | + uname -a + if grep -qi qemu /proc/cpuinfo; then + echo "WARNING: qemu markers in cpuinfo — this board looks emulated" + else + echo "OK: no qemu markers in cpuinfo" + fi + + - name: Install JDK 21, PostgreSQL, Redis + run: | + sudo apt-get update + sudo apt-get install -y postgresql redis-server curl unzip + if ! sudo apt-get install -y openjdk-21-jdk-headless; then + curl -fsSL "https://api.adoptium.net/v3/binary/latest/21/ga/linux/riscv64/jdk/hotspot/normal/eclipse" -o /tmp/jdk21.tgz + mkdir -p "$HOME/jdk21" + tar -xzf /tmp/jdk21.tgz -C "$HOME/jdk21" --strip-components=1 + echo "$HOME/jdk21/bin" >> "$GITHUB_PATH" + fi + java -version + + - name: Start services and create the empty database + run: | + sudo service postgresql start + sudo service redis-server start + sudo -u postgres psql -v ON_ERROR_STOP=1 <<'SQL' + DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'skillhub') THEN + CREATE ROLE skillhub LOGIN PASSWORD 'skillhub_dev'; + END IF; + END $$; + SQL + sudo -u postgres psql -c "DROP DATABASE IF EXISTS skillhub_riscv_ci_docker;" + sudo -u postgres psql -c "CREATE DATABASE skillhub_riscv_ci_docker OWNER skillhub;" + + - name: Native riscv64 build (Maven on the board) + run: | + cd server + ./mvnw -B package -pl skillhub-app -am -DskipTests + + # Pre-flight: the docker execution-mode bind-mounts the workspace into + # the container. Runners whose job container shares the HOST docker + # daemon resolve -v sources against the host filesystem, where the job + # workspace does not exist — the mount comes up empty and the mode is + # unverifiable there (not a code defect; local Docker verifies fine). + # Probe the semantics, record them, and only run the timed verification + # when bind-mounts actually round-trip. + - name: Docker pre-flight (image pull + bind-mount round-trip) + id: docker_preflight + run: | + docker version --format 'client {{.Client.Version}} / server {{.Server.Version}}' + docker pull alpine:3.20 + mkdir -p "$PWD/.docker-probe" && echo bind-mount-OK > "$PWD/.docker-probe/probe.txt" + if docker run --rm -v "$PWD/.docker-probe:/probe" alpine:3.20 cat /probe/probe.txt | grep -q bind-mount-OK; then + echo "bind-mount round-trip OK — docker execution-mode is verifiable on this runner" + echo "mount_ok=true" >> "$GITHUB_OUTPUT" + else + echo "bind-mount round-trip FAILED: this runner's docker daemon resolves -v sources" + echo "against the HOST filesystem, where the job workspace path does not exist." + echo "docker execution-mode (workspace bind-mount) cannot be verified on this runner" + echo "class — local-Docker evidence (backend=docker in the event stream) stands in" + echo "docs/26-skill-authoring-platform.md instead." + echo "mount_ok=false" >> "$GITHUB_OUTPUT" + fi + rm -rf "$PWD/.docker-probe" + + - name: Verify with execution-mode=docker (scripts run in alpine:3.20 riscv64) + if: steps.docker_preflight.outputs.mount_ok == 'true' + env: + SKILLHUB_AUTHORING_LOCAL_SCRIPT_MODE: docker + run: | + SKILLHUB_RISCV_BOOT_MODE=jar SKILLHUB_RISCV_DB=skillhub_riscv_ci_docker \ + bash scripts/riscv64-verify.sh + echo "NOTE: the TOOL_RESULT event in the log above must say \"backend\":\"docker\"" + + backend-tests-on-riscv64: + if: github.repository == 'zjncs/skillhub' && github.event_name == 'workflow_dispatch' + # bpi-f3 boards never provisioned a runner; the VisionFive 2 pool works. + # Jobs on the same board type run sequentially — fine for a manual run. + runs-on: [self-hosted, visionfive2] + timeout-minutes: 300 + steps: + - uses: actions/checkout@v4 + + - name: Record the board (native-proof evidence) + run: | + uname -a + head -5 /proc/cpuinfo + if grep -qi qemu /proc/cpuinfo; then + echo "WARNING: qemu markers in cpuinfo — this board looks emulated" + else + echo "OK: no qemu markers in cpuinfo" + fi + docker version 2>/dev/null | head -4 || echo "docker not available in job" + + - name: Install JDK 21 + run: | + sudo apt-get update + sudo apt-get install -y curl unzip + if ! sudo apt-get install -y openjdk-21-jdk-headless; then + curl -fsSL "https://api.adoptium.net/v3/binary/latest/21/ga/linux/riscv64/jdk/hotspot/normal/eclipse" -o /tmp/jdk21.tgz + mkdir -p "$HOME/jdk21" + tar -xzf /tmp/jdk21.tgz -C "$HOME/jdk21" --strip-components=1 + echo "$HOME/jdk21/bin" >> "$GITHUB_PATH" + fi + java -version + + # Testcontainers pulls these during the suite; pulling first keeps the + # pull time out of the test timeouts (images stay cached on the board). + - name: Pre-pull Testcontainers images + run: | + docker pull postgres:16-alpine + docker pull alpine:3.20 + + # Best-effort probe: record whether a system browser exists on riscv64. + # Playwright ships no riscv64 driver, so full browser E2E stays on the + # dev machine; this step documents browser availability for the record. + - name: Probe system browser availability (best-effort) + continue-on-error: true + run: | + sudo apt-get install -y chromium-browser 2>/dev/null \ + || sudo apt-get install -y chromium 2>/dev/null \ + || sudo apt-get install -y firefox-esr 2>/dev/null \ + || true + (chromium --version || chromium-browser --version || firefox-esr --version) 2>/dev/null \ + || echo "no system browser package available on this riscv64 distro" + + - name: Full backend test suite on riscv64 + env: + TESTCONTAINERS_RYUK_DISABLED: "true" + run: | + cd server + ./mvnw -B test + + # The board's small disk fills up with the Maven repo, images and build + # artifacts; freeing space before the job ends keeps the runner's + # post-job cleanup (which failed once with disk pressure) healthy. + - name: Free disk space for the runner + if: always() + run: | + rm -rf server/*/target || true + docker image prune -f || true + df -h / | tail -1 diff --git a/Makefile b/Makefile index 42941a61e..934066148 100644 --- a/Makefile +++ b/Makefile @@ -5,16 +5,18 @@ DEV_SERVER_PID := $(DEV_DIR)/server.pid DEV_WEB_PID := $(DEV_DIR)/web.pid DEV_SERVER_LOG := $(DEV_DIR)/server.log DEV_WEB_LOG := $(DEV_DIR)/web.log -DEV_WEB_URL := http://localhost:3000 +DEV_WEB_PORT ?= 3000 +DEV_WEB_URL := http://localhost:$(DEV_WEB_PORT) DEV_WEB_HOST ?= 127.0.0.1 -DEV_API_URL := http://localhost:8080 +DEV_API_PORT ?= 8080 +DEV_API_URL := http://localhost:$(DEV_API_PORT) DEV_SCANNER_URL := http://localhost:8000 STAGING_API_URL := http://localhost:8080 STAGING_WEB_URL := http://localhost STAGING_SERVER_IMAGE := skillhub-server:staging DEV_PROCESS := bash scripts/dev-process.sh DEV_SERVER_PREPARE := true -DEV_SERVER_CMD := ./scripts/run-dev-app.sh +DEV_SERVER_CMD := ./scripts/run-dev-app.sh --server.port=$(DEV_API_PORT) DEV_SERVER_SCANNER_ENV := SKILLHUB_SECURITY_SCANNER_ENABLED=true SKILLHUB_SECURITY_SCANNER_URL=$(DEV_SCANNER_URL) SKILLHUB_SECURITY_SCANNER_MODE=upload BACKEND_TEST_JAVA_OPTIONS ?= -XX:+EnableDynamicAgentLoading PARALLEL_BASE_REF ?= origin/main @@ -49,7 +51,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端 echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \ else \ echo "Starting frontend..."; \ - $(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host $(DEV_WEB_HOST) --strictPort >/dev/null; \ + $(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- env VITE_API_PROXY_TARGET=$(DEV_API_URL) pnpm exec vite --host $(DEV_WEB_HOST) --port $(DEV_WEB_PORT) --strictPort >/dev/null; \ fi @echo "Waiting for backend on $(DEV_API_URL) ..." @backend_ready=0; \ @@ -250,7 +252,7 @@ web-install-ci: ## 以 CI 方式安装前端依赖 cd web && CI=true pnpm install --frozen-lockfile dev-web: ## 启动前端开发服务器 - cd web && pnpm exec vite --host $(DEV_WEB_HOST) + cd web && VITE_API_PROXY_TARGET=$(DEV_API_URL) pnpm exec vite --host $(DEV_WEB_HOST) --port $(DEV_WEB_PORT) build-frontend: web-deps ## 构建前端 cd web && pnpm run build diff --git a/docs/26-skill-authoring-platform.md b/docs/26-skill-authoring-platform.md new file mode 100644 index 000000000..6f2f745ec --- /dev/null +++ b/docs/26-skill-authoring-platform.md @@ -0,0 +1,382 @@ +# Skill 创作与验证平台设计、部署与测试 + +## 定位 + +Skill 创作与验证平台让作者在 SkillHub 内完成 Skill 的**创建、编辑、受控验证和提交发布**, +而不是在仓库外部手工打包再上传。核心闭环是: + +```text +创建草稿 → 编辑 SKILL.md/资源文件 → 绑定 Agent/工具/MCP 运行时 + → 三层验证(结构/配置/行为,含日志与调用轨迹) + → 定位失败步骤 → 应用修复建议 → 复验 + → 达标草稿提交,复用既有扫描/审核/发布管线 +``` + +平台不引入第二套发布通道:提交动作直接复用 `SkillPublishService`,草稿只是把 +"写一个符合规范的 Skill" 变成了带验证门槛的结构化流程。 + +## 整体架构 + +```text +web/ React 工作台 + pages/authoring/drafts.tsx 草稿列表 + 创建 + pages/authoring/draft-detail.tsx 文件/运行时/运行记录三个标签页 + pages/authoring/run-detail.tsx 运行详情:事件控制台 + 发现列表 + features/authoring/… SSE hook、事件合并、diff 预览、发现卡片 + │ REST + SSE + ▼ +skillhub-app + controller/SkillAuthoringController 19 个 REST 端点 + controller/ValidationStreamController SSE 事件流(回放 + 直播) + service/authoring/ValidationRunOrchestrator 编排三层验证、事件、终态 + service/authoring/adapter/* LocalScript(inline/docker)/ OpenAiCompatible 运行时适配器 + service/authoring/mcp/* MCP 客户端(http/sse/stdio)与配置层连通探测 + service/authoring/ValidationEventBroadcaster SSE 广播 + ▼ +skillhub-domain + authoring/ SkillDraft、DraftFile、RuntimeBinding 实体与服务 + authoring/validation ValidationRun / ValidationEvent / ValidationFinding + authoring/spec validation.yaml 解析(防御式 SnakeYAML) + authoring/runtime 任务执行结果与断言求值器 + skill/validation/ SkillPackagePolicy(结构规则与发布侧共享,规则不漂移) + ▼ +skillhub-storage 草稿文件按内容寻址存入对象存储(与 Skill 包同机制) +``` + +## 领域模型与数据表 + +迁移 `V68__authoring_platform.sql` 新增六张表(初稿编号 V60,因上游 #874 统一身份 +平台占用了 V60–V65,rebase 后顺延为 V66,上游 promotion 功能又占用了 V66–V67,再次顺延为 V68): + +| 表 | 职责 | +| --- | --- | +| `skill_draft` | 草稿元数据:归属人、名称、`revision`(每次内容变更 +1)、`contentDigest`、`validatedRevision`/`validatedRunId`、提交回填的 `submittedSkillId` | +| `draft_file` | 草稿内单个文件(路径、内容类型、对象存储 key、sha256) | +| `runtime_binding` | Agent 类型 + 适配器配置 + 工具白名单 + MCP 服务器声明(JSONB) | +| `validation_run` | 一次验证运行的快照:draft revision、状态、各层统计 | +| `validation_event` | 运行事件(日志、工具调用、阶段切换),`seq` 单调递增,供 SSE 回放 | +| `validation_finding` | 结构化发现:层级、规则码、严重度、`filePath`/位置、建议(JSONB) | + +关键不变量: + +- **乐观并发**:草稿保存文件带 `expectedRevision`,冲突返回 409,前端提示刷新后重试。 +- **验证即快照**:运行绑定启动时的 revision;`SUCCEEDED` 且 0 错误时回写 + `validatedRevision`。提交门槛是"当前 revision 必须恰好等于 `validatedRevision`", + 验证通过后再改动文件会自动失去提交资格,必须复验。 +- **运行状态机**:`QUEUED → PREPARING → RUNNING → SUCCEEDED / FAILED / CANCELLED / TIMED_OUT`; + 活跃运行支持协作式取消,超时由任务级 `timeoutMs` 与运行级 `run-timeout-ms` 双层控制, + 崩溃残留的活跃运行由维护任务按 `stale-run-minutes` 清扫为 `TIMED_OUT`。 + +## 三层验证流水线 + +`ValidationRunOrchestrator` 顺序执行三层,每层产出结构化发现而非异常字符串: + +1. **STRUCTURE(结构)**:`DraftStructureValidator` 检查包布局、路径合法性与去重、 + 扩展名白名单、内容与扩展名一致性、文件数与体积上限、SKILL.md frontmatter + 必填字段(name、description)。规则全部来自 `SkillPackagePolicy`,与发布侧 + 校验逐条对齐,草稿期能过的包到发布期不会再因结构被拒。 +2. **CONFIG(配置)**:`RuntimeBindingValidator` 校验 Agent 类型(`local-script` / + `openai-compatible`)、解释器白名单(`sh/bash/python3/node`)、工具白名单与 + MCP 声明(名称、transport、endpoint/command、工具过滤、env 引用),并扫描 + 绑定中任何凭据形态的字段(`CREDENTIAL_EMBEDDED`)——密钥只能存服务端配置。 + 同时对 validation.yaml 的全部断言做语法预检(类型合法、必填参数齐全、正则可编译), + 让错误在执行前暴露。声明的 MCP 服务器随后被**真实探测**:逐台连接、完成 + initialize 握手并 tools/list——连不上记 `MCP_CONNECT_FAILED` 错误(行为层 + 因此被阻断),`toolFilters` 引用服务器未提供的工具记 `MCP_TOOL_FILTER_UNKNOWN` + 告警,连接成功则在事件流留下工具清单日志。MCP 声明由此从"格式正确"变成 + "验证时确实可用"。 +3. **BEHAVIOR(行为)**:把草稿文件物化进一次性隔离工作区,按 validation.yaml + 逐任务执行(script 任务跑 `scripts/` 下的脚本,prompt 任务走 LLM 适配器), + 用 `AssertionEvaluator` 求值断言:`exit_code`、`stdout_contains`、`stdout_matches`、 + `stdout_json`(JSON Pointer 等值)、`artifact_exists`(可附 sha256)、 + `tool_call_count`,以及 prompt 侧的 `response_*` 系列。行为被真实执行验证, + 而不是靠元数据推断。 + +### 受控执行环境 + +`LocalScriptRuntimeAdapter` 是行为层默认运行时,有两种执行后端 +(`local-script.execution-mode`): + +- **inline**(默认,本地开发):脚本在服务器主机上以子进程执行。安全基线: + 每次运行独立临时工作区(`workspace-root` 下),脚本路径归一化后必须落在 + 工作区内,越界路径直接拒绝;进程环境被清空,只保留 `PATH`、`HOME` + (指向工作区)、`LANG`,外加绑定 `envAllowlist` 中显式点名的变量; + stdout/stderr 逐行流入事件流(作者实时可见),同时进入 256KB 截断缓冲用于 + 发现;解释器白名单外的命令不可执行;任务超时或运行取消时强制销毁进程。 +- **docker**(生产推荐):同一套脚本在锁定的容器内执行——`--network none` + (容器内无任何网络路由)、`--memory`/`--cpus`/`--pids-limit` 资源上限、 + 只读根文件系统(`--read-only`,工作区读写挂载到 `/workspace`、`/tmp` 为 + 限量 tmpfs)、`--cap-drop ALL` + `--security-opt no-new-privileges`、日志 + 驱动关闭。容器按运行标识命名,超时/取消时强制删除;Docker 不可用时该后端 + 报告不可用而不会静默回退。隔离效果由真实容器内的行为测试实证 + (`DockerScriptRuntimeAdapterTest`:路由表为空、根文件系统只读、工作区可写)。 + +`OpenAiCompatibleRuntimeAdapter` 处理 prompt 任务,是带工具执行的 agent 循环: +endpoint/model 来自绑定,API key 只从服务端 +`skillhub.authoring.openai-compatible.api-key` 解析,默认关闭。任务开始时连接 +绑定声明的每台 MCP 服务器并 tools/list,工具经服务器 `toolFilters` 与绑定全局 +`toolAllowlist`(空 = 不限制;条目匹配裸工具名或 `server.tool`)双重过滤后以 +function 形式暴露给模型;模型发起 tool_calls 时**真实调用**对应 MCP 服务器, +结果回填对话进入下一轮,轮数上限 `max-tool-rounds`(默认 4)。每轮对话、每次 +工具调用与结果都是事件流里的 `TOOL_CALL`/`TOOL_RESULT` 记录,`tool_call_count` +断言统计实际执行的工具调用数。 + +### 事件流(日志与调用轨迹) + +每个动作都是一条持久化事件:运行启停、阶段切换、任务工具调用(TOOL_CALL,含 +命令与参数)、任务结果、stdout/stderr 逐行日志、断言通过摘要。事件带单调 `seq`, +SSE 端点 `/api/web/authoring/runs/{runId}/events/stream` 支持从 `afterSeq` / +`Last-Event-ID` 回放再续播,终端运行的流会在回放后正常收尾,刷新页面不丢历史。 + +## 失败定位与修复建议闭环 + +发现(finding)携带层级、规则码、`filePath` + 位置(失败步骤定位)和可选的 +`FixSuggestion`:一个或多个 `FilePatch`(文件路径、旧内容 sha256、旧值、新值)。 +前端 `FindingCard` 渲染红绿 diff 预览,应用走两步确认(Apply → Confirm),后端 +`FindingFixService.apply` 校验文件未被并发修改后写入补丁并推进 `revision`; +`dismiss` 记录作者已知悉。修复后一键"Fix and re-validate"复验——示例 Skill 的 +实测:frontmatter 缺 `description` → `FRONTMATTER_FIELD_MISSING` 带定位与补丁 → +应用后 revision 2→3 → 复验 `SUCCEEDED`。 + +## 提交与发布管线衔接 + +`DraftSubmitService.submit` 的门槛:当前 revision 存在 0 错误的 `SUCCEEDED` 运行 +(告警已由验证过程向作者展示,视为已确认)。通过后把草稿文件打包交给 +`SkillPublishService`:重新校验包结构、触发安全扫描、进入既有审核流,草稿回填 +`submittedSkillId`/`submittedVersionId`。也就是说验证平台产出的是"已自证行为"的 +Skill 版本,扫描与审核沿用平台原有机制,没有旁路。 + +## API 一览 + +前缀 `/api/web/authoring`(同时挂 `/api/v1/authoring`): + +| 端点 | 说明 | +| --- | --- | +| `POST /drafts`、`GET /drafts`、`GET/DELETE /drafts/{id}` | 草稿 CRUD(创建时生成 SKILL.md 脚手架,validation.yaml 由作者按需在工作台新建) | +| `GET /drafts/{id}/files`、`PUT /drafts/{id}/files`、`GET /drafts/{id}/files/content`、`DELETE /drafts/{id}/files` | 文件列表 / 保存(带 expectedRevision)/ 读取 / 删除 | +| `GET/PUT /drafts/{id}/runtime` | 运行时绑定读写 | +| `POST /drafts/{id}/runs`、`GET /drafts/{id}/runs` | 启动验证 / 运行历史(版本与验证记录持久化) | +| `GET /runs/{id}`、`POST /runs/{id}/cancel` | 运行详情 / 取消活跃运行 | +| `GET /runs/{id}/events`、`GET /runs/{id}/events/stream` | 事件轮询 / SSE 流 | +| `GET /runs/{id}/findings`、`POST /runs/{id}/findings/{fid}/apply`、`POST /runs/{id}/findings/{fid}/dismiss` | 发现列表 / 应用修复 / 忽略 | +| `POST /drafts/{id}/submit` | 达标草稿提交发布 | + +## 前端工作台 + +路由(均需登录):`/dashboard/authoring`(列表)、`/dashboard/authoring/$draftId` +(文件 / 运行时 / 运行记录)、`/dashboard/authoring/$draftId/runs/$runId`(运行详情)。 + +- 文件编辑器支持新建/删除/保存,未保存标记 + 乐观并发冲突提示; +- 运行时表单按 Agent 类型切换字段(解释器 vs endpoint/model),工具白名单与 + MCP 服务器声明有即时校验; +- 运行详情页 = 状态卡 + 自动滚动事件控制台(SSE 直播,断线 2 秒轮询降级)+ + 发现卡片列表;活跃运行每 2 秒轮询状态直到终态; +- 文案覆盖 en/zh/ru 三语言。 + +## 部署 + +### 配置项(`skillhub.authoring.*`,均可经环境变量覆盖) + +| 配置 | 默认 | 说明 | +| --- | --- | --- | +| `workspace-root` | `${java.io.tmpdir}/skillhub-authoring` | 隔离工作区根目录(`SKILLHUB_AUTHORING_WORKSPACE_ROOT`) | +| `executor-threads` | 4 | 进程内并发验证线程数 | +| `run-timeout-ms` | 900000 | 单次运行硬上限(15 分钟) | +| `stale-run-minutes` | 30 | 崩溃恢复清扫阈值 | +| `local-script.enabled` | true | 本地脚本运行时开关 | +| `local-script.execution-mode` | **docker** | 脚本执行后端:`docker`(锁定容器,生产默认)/ `inline`(主机子进程,仅 local/dev/test profile;否则启动即失败,`SKILLHUB_AUTHORING_LOCAL_SCRIPT_MODE`) | +| `local-script.docker.image` / `memory` / `cpus` / `pids-limit` / `tmpfs-size` | alpine:3.20 / 256m / 1.0 / 128 / 64m | 容器隔离参数(`SKILLHUB_AUTHORING_DOCKER_*`);镜像需自带 Skill 用到的解释器 | +| `openai-compatible.enabled` | false | LLM 运行时开关(默认关闭) | +| `openai-compatible.api-key` | 空 | 服务端密钥,绝不写入草稿绑定 | +| `openai-compatible.default-endpoint` / `default-model` | 空 | prompt 任务的默认 LLM | +| `openai-compatible.max-tool-rounds` | 4 | 单个 prompt 任务的最大对话轮数(含 MCP 工具执行) | +| `openai-compatible.allow-private-endpoints` | false | LLM endpoint 的 SSRF 放行开关:私网 LLM(如内部 vLLM)才设 true(`SKILLHUB_AUTHORING_LLM_ALLOW_PRIVATE`) | +| `mcp.allow-private-endpoints` | false | http/sse MCP endpoint 的 SSRF 放行开关(`SKILLHUB_AUTHORING_MCP_ALLOW_PRIVATE`);链路本地/云元数据地址任何情况下都拒绝 | +| `mcp.stdio-enabled` | false | stdio MCP 传输总开关(`SKILLHUB_AUTHORING_MCP_STDIO_ENABLED`)——服务器侧进程执行,生产默认关闭 | +| `mcp.env-allowlist` | 空 | 允许 `envRefs` 引用的服务器环境变量名单(逗号分隔,`SKILLHUB_AUTHORING_MCP_ENV_ALLOWLIST`);默认空 = 全部拒绝 | +| `mcp.docker.image` / `memory` / `cpus` / `pids-limit` / `tmpfs-size` | alpine:3.20 / 256m / 1.0 / 128 / 64m | docker 模式下 stdio MCP 服务器的容器隔离参数(`SKILLHUB_AUTHORING_MCP_DOCKER_*`) | + +### 部署要点 + +- **数据库**:Flyway 迁移 V68 自动建表,无手工步骤;JSONB 列要求 PostgreSQL + (平台既有要求,无新增)。 +- **启用 prompt 任务**:配置 `SKILLHUB_AUTHORING_LLM_ENABLED=true` 并给出 + endpoint/model/api-key;不启用时含 prompt 任务的草稿会得到 `RUNTIME_DISABLED` + 配置层发现,script 流程不受影响。 +- **启用容器隔离**:`SKILLHUB_AUTHORING_LOCAL_SCRIPT_MODE=docker` 并确保运行节点 + 可访问 Docker daemon。镜像需包含 Skill 用到的解释器(alpine 只带 `sh`; + python3/node 的 Skill 应换基础镜像)。脚本执行不再依赖主机环境。 +- **MCP 服务器**:stdio 传输默认整体关闭(`mcp.stdio-enabled`),开启后命令在 + docker 执行模式下运行于与脚本相同的锁定容器(无网络、资源上限、只读根文件 + 系统,关闭时强制 `docker rm -f` 清理);inline 模式仅限 local/dev/test + profile。`envRefs` 只能引用 `mcp.env-allowlist` 点名的环境变量(默认空 = + 全部拒绝),这是凭据到达 MCP 服务器的唯一通道。http/sse 传输访问的 + endpoint 必须通过 SSRF 策略(见下)。验证时每台声明的服务器都会被真实探测。 +- **工作区**:验证运行会在 `workspace-root` 下创建一次性目录并在结束后清理, + 生产环境建议独立磁盘分区并纳入监控;多实例部署时运行在工作区所在节点本地 + 执行,无需共享存储。 +- **RISC-V64**:后端是架构无关的 Java 21 JAR,沿用 `skillhub-server` 既有 + `linux/riscv64` 构建路径(见 `docs/RISCV64.md`)。local-script 运行时只依赖 + 容器内 POSIX 工具(`sh`、`wc` 等),Temurin 运行时镜像自带;安全扫描器的 + RISC-V 限制与创作平台无关(扫描发生在提交后的发布管线)。 +- **安全基线**: + - **SSRF 防护**:MCP http/sse endpoint 与 OpenAI 兼容 endpoint 在保存时、 + 连接时双重校验——只允许 http(s),主机必须可解析,且解析到的地址不得为 + 链路本地(含云元数据 169.254.169.254)、组播或未指定地址(无条件拒绝), + 也不得为环回/RFC1918/ULA/CGNAT/RFC 2544 基准段 198.18.0.0/15(除非对应 + 表面的 `allow-private-endpoints` 放行;198.18.0.0/15 为保留段,也是 + fake-IP 代理/VPN 解析器的合成地址池)。HTTP 客户端从不跟随重定向(30x + 直接报错),单响应体上限 2MiB,stdio 单行上限 2MiB——阻断"公网 URL 302 + 跳元数据"与超大响应/超长行的内存耗尽攻击。已知边界:策略在检查时解析 + 域名,HttpClient 连接时再次解析,可缓解但无法完全消除 DNS rebinding + 竞态(绑定已校验地址的 socket 会破坏虚拟主机 endpoint,未实现)。 + - **生产强制容器**:`execution-mode` 默认 `docker`;若在非 local/dev/test + profile 下配置了 inline,应用启动即失败(`AuthoringStartupGuard`)。 + - **其余既有边界**:脚本在清空环境的最小工作区执行、解释器白名单、路径 + 不得越界、绑定禁止内嵌凭据、单文件 10MB / 包 100MB / 500 文件上限与 + 发布侧一致。 + +### RISC-V64 验证记录(2026-09-20) + +在 arm64 宿主机上用 Buildx + QEMU 对本功能构建 `linux/riscv64` 服务端镜像并完成 +活体验证(对 `docs/RISCV64.md` 组件镜像边界的实测补充): + +- **镜像构建**:`server/Dockerfile` 原生路径(JAR 在构建宿主侧编译,运行时基于 + `eclipse-temurin:21-jre-noble` 的 riscv64 官方变体),`docker image inspect` + 确认 `linux/riscv64`。 +- **启动**:QEMU 模拟下 Spring Boot 91.9 秒完成启动,`/actuator/health` 返回 + 200;Flyway 在全新 PostgreSQL 16 上完成全部迁移(含 V68 创作平台表)。 +- **创作平台全流程(REST API)**:创建草稿 → 读取脚手架 SKILL.md → 保存 + `scripts/check.sh` 与 `validation.yaml` → 保存 local-script 绑定 → 启动验证 → + 运行 `SUCCEEDED`(0 错误 0 警告),12 条事件完整落库且脚本 stdout 出现在事件 + 流中 —— 行为层的脚本子进程真实运行在 riscv64 用户态内。 +- **结论**:创作与验证平台不引入任何架构相关依赖(纯 JVM 字节码 + POSIX + 工具),随 `skillhub-server` 既有 `linux/riscv64` 发布路径交付即可; + `execution-mode: docker` 时基础镜像需有 riscv64 变体(默认 `alpine:3.20` + 官方提供)。 +- **边界**:本记录为 QEMU 模拟验证,与 CI 的 riscv64 镜像 guardrail 同级;原生 + RISC-V 硬件上的全栈冒烟(PostgreSQL/Redis/对象存储/scanner)仍按 + `docs/RISCV64.md` 的边界声明执行。 + +#### 复验脚本与原生硬件清单 + +上述流程已固化为 `scripts/riscv64-verify.sh`(自动识别宿主架构:riscv64 主机 +原生执行,其余走 QEMU;对独立数据库跑 创建→验证→SUCCEEDED→事件流检查)。 +拿到真机后,`scripts/riscv64-native-setup.sh` 可以一键完成环境准备:安装 +Docker/PostgreSQL/Redis(apt 与 dnf 系)、建空库、放通 docker 网桥、原生构建 +镜像并跑完验证,全过程落盘为 `riscv64-native-evidence-*.log`。注意原生构建 +需 `--build-arg BUILD_IMAGE=eclipse-temurin:21-jdk-noble`(Dockerfile 默认的 +Alpine JDK 构建镜像无 riscv64 变体,Noble 有)——setup 脚本已自动处理。 +在真实 RISC-V64 硬件或 CI runner 上完成原生验证时,除直接运行该脚本外, +按以下清单执行并留档: + +1. `uname -m` 输出 `riscv64`(非 QEMU:`/proc/cpuinfo` 无 "emodel"/QEMU 标记, + 且启动日志无 qemu 前缀); +2. PostgreSQL 16、Redis 7 以 riscv64 原生镜像运行(非 binfmt 转译); +3. `scripts/riscv64-verify.sh` 全部检查通过,记录 耗时 与 事件数 两个数字; +4. `execution-mode: docker` 下再跑一次(验证 alpine:3.20 riscv64 变体内的 + 脚本隔离执行); +5. 浏览器 E2E(`e2e/authoring-flow.spec.ts`)对准该实例跑通。 + +截至 2026-10-01,上述第 1–5 项中已有部分由**真机 CI**覆盖(见下节),第 4–5 +项仍待在独占真机环境补齐;PLCT / INCHI RVLab 的真机使用申请已提交 +(开源项目免费通道,开通周期约一周),获批后可补齐剩余项。 + +#### 真机 RISC-V64 CI 证据(2026-10-02,Cloud-V 板卡 runner) + +`zjncs/skillhub` 仓库的 `.github/workflows/riscv64-native.yml` 挂在 +Cloud-V / 10xEngineers 提供的免费真机 GitHub runner 上(物理 VisionFive 2 +板卡,每次任务全新容器,无需账号或 token)。首次运行 +([run 37035583787](https://github.com/zjncs/skillhub/actions/runs/37035583787), +提交 `9f3451d2`)**全绿,全程约 14 分钟**: + +- **板卡原生性**:`Linux sf2-4 7.0.0-30-generic #30.1~24.04.1-Ubuntu … + riscv64 riscv64 riscv64 GNU/Linux`,`/proc/cpuinfo` 无 qemu 标记; +- **原生构建**:板卡上 apt 安装 openjdk-21 / postgresql / redis-server 后, + Maven 在 riscv64 上完成整个多模块构建; +- **验证流程**(`scripts/riscv64-verify.sh` jar 模式,7/7 通过):服务器在 + riscv64 用户态运行 → 健康检查通过 → 创建草稿 → 脚手架读取 → 绑定 → + 验证运行 `SUCCEEDED` → 脚本 stdout 出现在事件流(12 条事件)。 +- **全量后端测试在真机首跑(2026-10-08)**:1085 个测试中 1081 个通过, + 仅有的 4 个失败全部来自 `DockerScriptRuntimeAdapterTest`——即上文共享 + daemon 的 bind-mount 限制(环境问题,非架构问题)。该测试现已加入 + bind-mount 往返假设(与"无 Docker 时静默跳过"同一模式):在此类 + runner 上优雅跳过,在真实 Docker 环境照常执行(本地验证 4/4 通过)。 + 复跑确认:**`BUILD SUCCESS`,1081 个测试 0 失败 0 错误**(38 分 42 秒, + 4 个 docker 适配器测试按假设跳过),且整个 run 三 job 全绿 + ([run 37762771959](https://github.com/zjncs/skillhub/actions/runs/37762771959), + 含磁盘清理步骤以规避板卡收尾清理的磁盘压力问题)。——整个后端套件 + 在物理 RISC-V64 板卡上原生通过。 + +对照上面的原生清单:第 1、3 项已由此覆盖;第 2 项的平台服务在板卡上以 +riscv64 原生包运行(容器镜像的 riscv64 变体另行经 `docker manifest inspect` +验证:`postgres:16`/`alpine:3.20`/`eclipse-temurin:21-jre-noble` 均有, +`redis:7` 无);第 4 项(docker 执行模式重跑)在板卡 CI 上**不可验证**—— +Cloud-V 这类共享宿主 daemon 的 runner 会按宿主文件系统解析 bind-mount 源 +路径,job 容器内的工作区路径在宿主上不存在(预检往返测试留档: +`cat: can't open '/probe/probe.txt'`),docker 执行模式的验证以本地真实 +Docker 环境的证据为准(验证事件流记录 `backend: docker`、退出码 0,且 +`DockerScriptRuntimeAdapterTest` 在真实容器中实证隔离参数);第 5 项 +(浏览器 E2E)待独占真机环境补齐。该 workflow 带 `if: github.repository == 'zjncs/skillhub'` 守卫, +上游仓库无此 runner,不会被排队。 + +### 发布管线端到端证据(2026-09-21) + +`scripts/authoring-publish-e2e.sh` 在本地 dev 栈(PostgreSQL/Redis/MinIO + +skill-scanner :8000,`local` profile)上实跑全链路并留档,16/16 检查通过: + +- **创作与验证**(普通用户 `local-user`):草稿 94 → SKILL.md/`scripts/check.sh`/ + `validation.yaml` → local-script 绑定 → 验证运行 `SUCCEEDED`; +- **提交 PUBLIC**:submit 返回 skill 33 / version 33 / slug + `publish-evidence-1789957612` / 版本号 `20260921.102653`,版本进入 + `SCANNING`(非 SUPER_ADMIN 提交不会自动发布); +- **安全扫描**:异步完成后版本回到 `PENDING_REVIEW`,`security_audit` 落库 + `SKILL_SCANNER:SAFE:1`(1 条发现、判定安全); +- **人工审核**:平台管理员(`local-admin`,SUPER_ADMIN)通过 + `POST /api/web/reviews/1/approve` 批准,审核任务 `APPROVED`; +- **发布终态**:`skill_version.status = PUBLISHED`、`published_at` 已置位、 + `skill.visibility = PUBLIC`,另一普通用户(`local-plain-user`)可公开检索 + 该 Skill,且草稿回填 `submittedSkillId = 33`。 + +## 测试 + +| 层 | 位置 | 覆盖 | +| --- | --- | --- | +| 领域单测 | `skillhub-domain/…/authoring/` | `DraftStructureValidatorTest`(结构规则与修复建议)、`ValidationSpecParserTest`(防御式 YAML 解析、任务/断言语义)、`AssertionEvaluatorTest`(全部断言类型与失败信息)、`SkillScaffoldGeneratorTest` | +| 运行时单测 | `skillhub-app/…/authoring/adapter/` | `DockerScriptCommandBuilderTest`(隔离参数与挂载构造)、`DockerScriptRuntimeAdapterTest`(真实容器内实证:无网络路由、只读根文件系统、工作区可写、输出捕获;无 Docker 时静默跳过)、`OpenAiCompatibleRuntimeAdapterToolLoopTest`(agent 循环:MCP 工具发现→模型调用→真实执行→轨迹回填,toolFilters/toolAllowlist 过滤、轮数上限) | +| MCP 单测 | `skillhub-app/…/authoring/mcp/` | `HttpMcpClientTest`(initialize/tools 握手、会话头复用、SSE 帧解析、错误结果、不可达报错、**30x 重定向不跟随、2MiB 响应上限**,对真实本地 HTTP 服务器)、`StdioMcpClientTest`(stdio 传输对 python3 子进程;**2MiB 单行上限 + 进程销毁、关闭时清理命令执行**)、`McpProbeServiceTest`(连不上→`MCP_CONNECT_FAILED`、未知 toolFilter→告警、可跳过畸形声明)、`McpClientFactoryTest`(**连接时安全策略:被拒 endpoint/被禁 stdio 不建连不建进程、envRefs 白名单过滤、docker 包装参数**) | +| 安全策略单测 | `skillhub-app/…/config/` | `ConfiguredAuthoringSecurityPolicyTest`(SSRF 地址分类:云元数据/链路本地无条件拒绝、环回/RFC1918/ULA/CGNAT/198.18.0.0/15 按表面开关、公网放行、不可解析与 fake-IP 合成应答均拒绝——经注入 resolver,不依赖网络)、`AuthoringStartupGuardTest`(非 local/dev/test profile + inline → 启动失败) | +| 端到端集成 | `skillhub-app/…/authoring/AuthoringFlowIntegrationTest` | Testcontainers 真实 PostgreSQL 上跑通完整闭环:建草稿 → 改文件 → 绑定运行时 → 三层验证 → 修复发现 → 复验 → 提交(`ddl-auto=validate` 顺带校验 V68 与实体映射一致) | +| 前端单测 | `web/src/features/authoring/*.test.*` | 事件按 seq 合并去重、SSE 生命周期(回放合并、终态关闭、断线轮询降级)、修复 diff 预览、二进制文件 base64 处理 | +| 浏览器 E2E | `web/e2e/authoring-flow.spec.ts` | Playwright 真实 API 全 UI 闭环:创建草稿(命名空间/名称对话框)→ 文件编辑(SKILL.md/脚本/validation.yaml)→ 保存运行时绑定 → 启动验证至 Succeeded(事件控制台含脚本输出)→ 提交对话框过闸;坏 frontmatter → 失败发现 → diff 预览 → 两步确认应用修复 → 一键复验通过;二进制资源上传 → 只读面板(大小/类型/sha256)与字节级校验。断言落在持久 UI 状态(按钮态、徽章、响应体)而非易失 toast | +| 活体冒烟 | `scripts/authoring-smoke-test.sh` | 对运行中的服务器 35 项检查、四个场景:全层验证并提交;坏 frontmatter → 定位 → 应用修复 → 复验;守卫(未验证不可提交、跨用户不可读他人草稿);MCP 探测(死端点报 `MCP_CONNECT_FAILED`、本地假 MCP 服务器工具被发现并写入事件流、未知 toolFilter 仅告警),幂等可重复执行 | +| 示例 Skill | `examples/skill-authoring/` | 可直接装载的完整示例(含 4 个行为验证用例,覆盖全部 6 种 script 断言),README 给出 UI 与 API 两种装载方式 | + +常用命令: + +```bash +# 后端(需 Java 21;默认 JVM 24 会破坏 Mockito/ByteBuddy) +cd server && JAVA_HOME= ./mvnw -pl skillhub-app -am test + +# 前端 +cd web && pnpm lint && pnpm typecheck && pnpm test + +# 活体冒烟(先启动 dev profile 服务器) +./scripts/authoring-smoke-test.sh http://localhost:8082 + +# 浏览器 E2E(后端跑在 8082,vite 由 Playwright 拉起并把 /api 代理过去) +cd web && VITE_API_PROXY_TARGET=http://localhost:8082 pnpm test:e2e -- e2e/authoring-flow.spec.ts + +# 重新生成前端 API 类型 +cd web && pnpm exec openapi-typescript http://localhost:8082/v3/api-docs \ + -o src/api/generated/schema.d.ts +``` + +## 边界与非目标 + +- v1 的行为验证以确定性断言为主,语义质量评判不强行近似(归属模型辅助审核); +- 不支持草稿多人协作编辑(单一 owner + 乐观锁防误覆盖); +- 提交后草稿回填 `submittedSkillId`/`submittedVersionId` 作为回执,但并不锁定: + 继续编辑会推进 revision 并使已验证标记失效,再次提交前必须重新验证; +- MCP 工具调用发生在验证运行内(配置层探测 + prompt 任务执行),平台不充当 + 常驻的 MCP 网关;stdio MCP 命令与脚本任务共享同一台运行节点。 diff --git a/examples/skill-authoring/README.md b/examples/skill-authoring/README.md new file mode 100644 index 000000000..0da7af3a3 --- /dev/null +++ b/examples/skill-authoring/README.md @@ -0,0 +1,86 @@ +# Example skill: text-stats + +A complete, self-contained example for the Skill Authoring & Validation +platform. It demonstrates every artifact an author must produce and every +script assertion type the behavior layer can check. + +``` +examples/skill-authoring/ +├── SKILL.md # skill description: frontmatter (name, description) + usage docs +├── validation.yaml # behavior validation cases (4 tasks, 6 assertion types) +├── scripts/ +│ └── text-stats.sh # the executable helper: text file → JSON statistics +└── references/ + ├── metrics.md # background material the skill cites + └── sample.txt # fixture input with known counters (2 lines, 20 words) +``` + +The skill computes word/line/character statistics for a text file and emits a +single-line JSON object, which it also mirrors to `artifacts/report.json`: + +```json +{"input":"references/sample.txt","lines":2,"words":20,"characters":144} +``` + +## Validation cases + +| Task | What it proves | Assertion types used | +| -------------------- | ----------------------------------------------------- | -------------------- | +| `fixture-stats` | counters are correct for the bundled fixture | `exit_code`, `stdout_contains`, `stdout_json`, `tool_call_count` | +| `output-shape` | stdout is one JSON object with exactly the known keys | `exit_code`, `stdout_matches` | +| `report-artifact` | the run leaves a persistent report artifact | `exit_code`, `artifact_exists` | +| `rejects-missing-file` | bad usage fails loudly instead of printing garbage | `exit_code` | + +A valid run finishes `SUCCEEDED` with 4 tasks and 0 findings. To see the fix +loop in action, change the `/words` expectation to `21` and re-validate: the +run fails with an `ASSERTION_FAILED` finding pointing at the exact task. + +## Loading it into the platform + +### Option A: web workbench + +1. Sign in and open **Skill 创作** (Authoring) from the sidebar. +2. Create a draft named `text-stats` in any namespace you own. +3. In the **Files** tab, replace the scaffolded `SKILL.md`, then create + `validation.yaml`, `scripts/text-stats.sh`, and the two `references/` + files with the contents from this directory (paths matter, names are + up to you). +4. In the **Runtime** tab bind agent type **local-script** with interpreter + `sh`, no tool allowlist, no MCP servers. +5. Click **Validate** and watch the run stream in real time — structure, + config, then behavior events appear as they happen. +6. When the run succeeds, **Submit** pushes the draft into the existing + scan/review/publish pipeline. + +### Option B: REST API + +With the dev profile running (mock auth), the same flow is four calls — +see `scripts/authoring-smoke-test.sh` for a complete, runnable version +including CSRF bootstrapping: + +```sh +BASE=http://localhost:8082 +H='-H Content-Type:application/json -H X-Mock-User-Id:local-user' + +# 1. create the draft (id from the response) +curl -s $H -X POST $BASE/api/web/authoring/drafts \ + -d '{"namespaceSlug":"global","name":"text-stats","requirement":"text statistics as JSON"}' + +# 2. save each file (repeat for every path above) +curl -s $H -X PUT $BASE/api/web/authoring/drafts//files \ + -d '{"path":"SKILL.md","content":""}' + +# 3. bind the runtime +curl -s $H -X PUT $BASE/api/web/authoring/drafts//runtime \ + -d '{"agentType":"local-script","config":{"interpreter":"sh"},"toolAllowlist":[],"mcpServers":[]}' + +# 4. run validation, then poll the run / stream its events +curl -s $H -X POST $BASE/api/web/authoring/drafts//runs +curl -sN $BASE/api/web/authoring/runs//events/stream # live SSE +``` + +Scripts execute in an isolated workspace with a scrubbed environment (only +`PATH`, `HOME`, `LANG` plus variables named in the binding's `envAllowlist` +pass through), so the example deliberately sticks to `wc`/`tr`/`printf`, +which every Unix provides. Allowed interpreters are `sh`, `bash`, `python3`, +and `node`. diff --git a/examples/skill-authoring/SKILL.md b/examples/skill-authoring/SKILL.md new file mode 100644 index 000000000..e28583286 --- /dev/null +++ b/examples/skill-authoring/SKILL.md @@ -0,0 +1,33 @@ +--- +name: text-stats +description: computes word, line, and character statistics for a text file and reports them as JSON +--- + +# text-stats + +## Overview + +computes word, line, and character statistics for a text file and reports them as JSON. Agents use this skill whenever they need reproducible, machine-readable metrics about a piece of text — for example to compare a document before and after rewriting it. + +## Usage + +1. Locate the text file to analyze. Any plain-text file in the workspace works; `references/sample.txt` is a ready-made fixture. +2. Run the helper with the file path as the only argument: + + ```sh + sh scripts/text-stats.sh references/sample.txt + ``` + +3. Read the single-line JSON object from stdout: + + ```json + {"input":"references/sample.txt","lines":2,"words":20,"characters":144} + ``` + +4. The same object is mirrored to `artifacts/report.json` when a persistent copy is needed. + +## Resources + +- `references/metrics.md` — how each counter is defined +- `references/sample.txt` — sample input used by the validation cases +- `scripts/` — executable helpers invoked during task execution diff --git a/examples/skill-authoring/references/metrics.md b/examples/skill-authoring/references/metrics.md new file mode 100644 index 000000000..d5467bbb9 --- /dev/null +++ b/examples/skill-authoring/references/metrics.md @@ -0,0 +1,15 @@ +# Metric definitions + +All counters come from `wc(1)`, so results are reproducible across platforms +without extra dependencies. + +| Metric | Definition | +| ------------ | ---------------------------------------------------------------- | +| `lines` | Newline characters in the input (`wc -l`). A file whose last line has no trailing newline counts one line less. | +| `words` | Whitespace-separated tokens (`wc -w`). | +| `characters` | Bytes in the file (`wc -c`), so a multi-byte UTF-8 character counts once per byte. | + +The JSON object is emitted as a single line on stdout and mirrored to +`artifacts/report.json`. Invalid usage (wrong argument count or a missing +file) exits with code 2 and writes nothing, so a caller can distinguish +"computed" from "refused" without parsing error text. diff --git a/examples/skill-authoring/references/sample.txt b/examples/skill-authoring/references/sample.txt new file mode 100644 index 000000000..6d6112803 --- /dev/null +++ b/examples/skill-authoring/references/sample.txt @@ -0,0 +1,2 @@ +SkillHub makes agent skills discoverable, versioned, and trustworthy. +Every skill ships a validation spec so behavior is verified, not assumed. diff --git a/examples/skill-authoring/scripts/text-stats.sh b/examples/skill-authoring/scripts/text-stats.sh new file mode 100755 index 000000000..ab7148328 --- /dev/null +++ b/examples/skill-authoring/scripts/text-stats.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# text-stats.sh — print word/line/character statistics for a text file as JSON. +# +# Usage: sh scripts/text-stats.sh +# Emits a single-line JSON object on stdout and mirrors it to artifacts/report.json. +set -eu + +if [ "$#" -ne 1 ]; then + echo "usage: text-stats.sh " >&2 + exit 2 +fi + +INPUT="$1" +if [ ! -f "$INPUT" ]; then + echo "input file not found: $INPUT" >&2 + exit 2 +fi + +LINES=$(wc -l < "$INPUT" | tr -d ' ') +WORDS=$(wc -w < "$INPUT" | tr -d ' ') +CHARS=$(wc -c < "$INPUT" | tr -d ' ') + +mkdir -p artifacts +printf '{"input":"%s","lines":%s,"words":%s,"characters":%s}\n' \ + "$INPUT" "$LINES" "$WORDS" "$CHARS" | tee artifacts/report.json diff --git a/examples/skill-authoring/validation.yaml b/examples/skill-authoring/validation.yaml new file mode 100644 index 000000000..d19e2970a --- /dev/null +++ b/examples/skill-authoring/validation.yaml @@ -0,0 +1,66 @@ +# Behavior validation cases for the text-stats skill. +# +# Every task runs the skill's own script inside the isolated validation +# workspace. Together the tasks exercise all six script assertion types: +# exit_code, stdout_contains, stdout_json, stdout_matches, artifact_exists, +# and tool_call_count. +version: 1 +tasks: + # Happy path: the fixture produces the documented counters. + - name: fixture-stats + description: the helper reports correct counters for references/sample.txt + type: script + script: scripts/text-stats.sh + args: ["references/sample.txt"] + timeoutMs: 15000 + assertions: + - type: exit_code + equals: 0 + - type: stdout_contains + value: '"input":"references/sample.txt"' + - type: stdout_json + pointer: /words + equals: 20 + - type: stdout_json + pointer: /lines + equals: 2 + - type: tool_call_count + min: 1 + max: 1 + + # Output shape: one JSON line with exactly the documented keys. + - name: output-shape + description: stdout is a single JSON object with the documented keys + type: script + script: scripts/text-stats.sh + args: ["references/sample.txt"] + timeoutMs: 15000 + assertions: + - type: exit_code + equals: 0 + - type: stdout_matches + pattern: '^\{"input":"[^"]+","lines":\d+,"words":\d+,"characters":\d+\}$' + + # Side effect: the report artifact is written to the workspace. + - name: report-artifact + description: the run leaves a machine-readable report artifact + type: script + script: scripts/text-stats.sh + args: ["references/sample.txt"] + timeoutMs: 15000 + assertions: + - type: exit_code + equals: 0 + - type: artifact_exists + path: artifacts/report.json + + # Failure mode: bad usage exits non-zero instead of printing partial output. + - name: rejects-missing-file + description: the helper refuses missing input files with a non-zero exit code + type: script + script: scripts/text-stats.sh + args: ["references/does-not-exist.txt"] + timeoutMs: 15000 + assertions: + - type: exit_code + equals: 2 diff --git a/scripts/authoring-publish-e2e.sh b/scripts/authoring-publish-e2e.sh new file mode 100755 index 000000000..7fc287623 --- /dev/null +++ b/scripts/authoring-publish-e2e.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Publish-pipeline end-to-end evidence run for the authoring platform. +# +# Extends scripts/authoring-smoke-test.sh past the submit step: a validated +# draft is submitted as PUBLIC by a regular user, the security scanner +# (skillhub-skill-scanner-1) scans it asynchronously, a platform admin +# approves the review task, and the version reaches the PUBLISHED terminal +# state with the skill publicly retrievable. +# +# Each check prints PASS/FAIL plus the observed identifiers so the output can +# be pasted directly as evidence. Requires the local dev profile (mock auth, +# Postgres/Redis/scanner/minio from the compose stack). Usage: +# scripts/authoring-publish-e2e.sh [base-url] + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +COOKIE="$(mktemp)" +ADMIN_COOKIE="$(mktemp)" +RUN_ID="$(date +%s)" +NAME="publish-evidence-$RUN_ID" +PSQL=(docker exec skillhub-postgres-1 psql -U skillhub -d skillhub -tAc) + +cleanup() { + rm -f "$COOKIE" "$ADMIN_COOKIE" +} +trap cleanup EXIT + +pass() { + echo "PASS: $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "FAIL: $1" + FAIL=$((FAIL + 1)) +} + +json_field() { + local json="$1" + local expr="$2" + JSON_INPUT="$json" python3 - "$expr" <<'PY' +import json +import os +import sys + +expr = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +value = data +for part in expr.split('.'): + if part.isdigit(): + value = value[int(part)] + else: + value = value[part] +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +PY +} + +assert_equals() { + local description="$1" + local expected="$2" + local actual="$3" + if [[ "$expected" == "$actual" ]]; then + pass "$description" + else + fail "$description (expected '$expected', got '$actual')" + fi +} + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + if [[ -n "$data" ]]; then + curl -sS -H "X-Mock-User-Id: local-user" -b "$COOKIE" -c "$COOKIE" \ + -H "X-XSRF-TOKEN: $CSRF" -H "Content-Type: application/json" \ + -X "$method" "$BASE_URL$path" -d "$data" + else + curl -sS -H "X-Mock-User-Id: local-user" -b "$COOKIE" -c "$COOKIE" \ + -H "X-XSRF-TOKEN: $CSRF" -X "$method" "$BASE_URL$path" + fi +} + +admin_api() { + local method="$1" + local path="$2" + local data="${3:-}" + if [[ -n "$data" ]]; then + curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" -H "Content-Type: application/json" \ + -X "$method" "$BASE_URL$path" -d "$data" + else + curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" -X "$method" "$BASE_URL$path" + fi +} + +await_run_terminal() { + local run_id="$1" + local status="" + for _ in $(seq 1 60); do + status="$(json_field "$(api GET "/api/web/authoring/runs/$run_id")" "data.status")" + case "$status" in + SUCCEEDED|FAILED|CANCELLED|TIMED_OUT) break ;; + esac + sleep 1 + done + echo "$status" +} + +await_db_value() { + local query="$1" + local expected="$2" + local timeout="${3:-60}" + local value="" + for _ in $(seq 1 "$timeout"); do + value="$("${PSQL[@]}" "$query" 2>/dev/null || true)" + [[ "$value" == "$expected" ]] && break + sleep 1 + done + echo "$value" +} + +echo "=== Authoring → Publish Pipeline E2E Evidence ===" +echo "Target: $BASE_URL Run: $RUN_ID" +echo + +curl -s -c "$COOKIE" -H "X-Mock-User-Id: local-user" "$BASE_URL/api/v1/auth/providers" >/dev/null +CSRF="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE" | tail -n 1)" +curl -s -c "$ADMIN_COOKIE" -H "X-Mock-User-Id: local-admin" "$BASE_URL/api/v1/auth/providers" >/dev/null +ADMIN_CSRF="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$ADMIN_COOKIE" | tail -n 1)" + +# ---------------------------------------------------------------- author → validate + +echo "--- Stage 1: author and validate (regular user local-user) ---" + +DRAFT_RESPONSE="$(api POST /api/web/authoring/drafts "{\"namespaceSlug\":\"global\",\"name\":\"$NAME\"}")" +assert_equals "Draft created" "0" "$(json_field "$DRAFT_RESPONSE" "code")" +DRAFT_ID="$(json_field "$DRAFT_RESPONSE" "data.id")" +echo " draft id: $DRAFT_ID" + +SKILL_MD="--- +name: $NAME +description: publish pipeline evidence skill for run $RUN_ID +--- + +# Publish pipeline evidence + +Runs a shell check that must succeed before submission. +" +api PUT "/api/web/authoring/drafts/$DRAFT_ID/files" \ + "{\"path\":\"SKILL.md\",\"content\":$(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<< "$SKILL_MD"),\"contentType\":\"text/markdown\"}" >/dev/null +api PUT "/api/web/authoring/drafts/$DRAFT_ID/files" \ + "{\"path\":\"scripts/check.sh\",\"content\":\"#!/bin/sh\necho publish-evidence-ok\n\",\"contentType\":\"text/x-shellscript\"}" >/dev/null +api PUT "/api/web/authoring/drafts/$DRAFT_ID/files" \ + "{\"path\":\"validation.yaml\",\"content\":\"version: 1\\ntasks:\\n - name: check\\n description: script prints the evidence marker\\n type: script\\n script: scripts/check.sh\\n args: []\\n timeoutMs: 10000\\n assertions:\\n - type: exit_code\\n equals: 0\\n - type: stdout_contains\\n value: publish-evidence-ok\\n\",\"contentType\":\"application/yaml\"}" >/dev/null + +BINDING_RESPONSE="$(api PUT "/api/web/authoring/drafts/$DRAFT_ID/runtime" \ + '{"agentType":"local-script","config":{"interpreter":"sh"},"toolAllowlist":[]}')" +assert_equals "local-script binding saved" "0" "$(json_field "$BINDING_RESPONSE" "code")" + +RUN_RESPONSE="$(api POST "/api/web/authoring/drafts/$DRAFT_ID/runs")" +RUN_ID_LOCAL="$(json_field "$RUN_RESPONSE" "data.id")" +assert_equals "Validation run SUCCEEDED" "SUCCEEDED" "$(await_run_terminal "$RUN_ID_LOCAL")" + +# ---------------------------------------------------------------- submit PUBLIC + +echo +echo "--- Stage 2: submit as PUBLIC (enters publish pipeline) ---" + +SUBMIT_RESPONSE="$(api POST "/api/web/authoring/drafts/$DRAFT_ID/submit" '{"visibility":"PUBLIC"}')" +assert_equals "Draft submitted as PUBLIC" "0" "$(json_field "$SUBMIT_RESPONSE" "code")" +SKILL_ID="$(json_field "$SUBMIT_RESPONSE" "data.skillId")" +VERSION_ID="$(json_field "$SUBMIT_RESPONSE" "data.versionId")" +SKILL_VERSION="$(json_field "$SUBMIT_RESPONSE" "data.version")" +SLUG="$(json_field "$SUBMIT_RESPONSE" "data.slug")" +echo " skill id: $SKILL_ID version id: $VERSION_ID slug: $SLUG version: $SKILL_VERSION" + +DB_STATUS="$("${PSQL[@]}" "SELECT status FROM skill_version WHERE id = $VERSION_ID")" +if [[ "$DB_STATUS" == "PENDING_REVIEW" || "$DB_STATUS" == "SCANNING" ]]; then + pass "Version entered pipeline in $DB_STATUS (not auto-published)" +else + fail "Version should be PENDING_REVIEW/SCANNING, got '$DB_STATUS'" +fi + +# ---------------------------------------------------------------- security scan + +echo +echo "--- Stage 3: asynchronous security scan (scanner on :8000) ---" + +SCAN_STATUS="$(await_db_value \ + "SELECT status FROM skill_version WHERE id = $VERSION_ID" "PENDING_REVIEW" 90)" +assert_equals "Scan completed, version back to PENDING_REVIEW" "PENDING_REVIEW" "$SCAN_STATUS" + +SCAN_ROW="$("${PSQL[@]}" "SELECT scanner_type || ':' || verdict || ':' || findings_count FROM security_audit WHERE skill_version_id = $VERSION_ID AND deleted_at IS NULL ORDER BY id DESC LIMIT 1")" +if [[ -n "$SCAN_ROW" && "$SCAN_ROW" != "" ]]; then + pass "security_audit row recorded: $SCAN_ROW" +else + fail "No security_audit row for version $VERSION_ID" +fi + +REVIEW_TASK_ID="$("${PSQL[@]}" "SELECT id FROM review_task WHERE skill_version_id = $VERSION_ID ORDER BY id DESC LIMIT 1")" +if [[ -n "$REVIEW_TASK_ID" ]]; then + pass "Review task $REVIEW_TASK_ID pending approval" +else + fail "No review task created for version $VERSION_ID" +fi + +# ---------------------------------------------------------------- admin approve + +echo +echo "--- Stage 4: platform admin approves (local-admin) ---" + +APPROVE_RESPONSE="$(admin_api POST "/api/web/reviews/$REVIEW_TASK_ID/approve" \ + '{"comment":"publish-pipeline evidence run"}')" +assert_equals "Review approved" "0" "$(json_field "$APPROVE_RESPONSE" "code")" +REVIEW_STATUS="$(json_field "$APPROVE_RESPONSE" "data.status")" +assert_equals "Review task status APPROVED" "APPROVED" "$REVIEW_STATUS" + +# ---------------------------------------------------------------- terminal state + +echo +echo "--- Stage 5: PUBLISHED terminal state ---" + +DB_STATUS="$("${PSQL[@]}" "SELECT status FROM skill_version WHERE id = $VERSION_ID")" +assert_equals "skill_version.status = PUBLISHED (DB)" "PUBLISHED" "$DB_STATUS" + +PUBLISHED_AT="$("${PSQL[@]}" "SELECT published_at FROM skill_version WHERE id = $VERSION_ID")" +if [[ -n "$PUBLISHED_AT" && "$PUBLISHED_AT" != "" ]]; then + pass "published_at set: $PUBLISHED_AT" +else + fail "published_at not set" +fi + +VISIBILITY="$("${PSQL[@]}" "SELECT visibility FROM skill WHERE id = $SKILL_ID")" +assert_equals "skill.visibility = PUBLIC (DB)" "PUBLIC" "$VISIBILITY" + +DETAIL="$(curl -sS -H "X-Mock-User-Id: local-plain-user" "$BASE_URL/api/web/skills/global/$SLUG")" +assert_equals "Skill publicly retrievable by another user" "0" "$(json_field "$DETAIL" "code")" +assert_equals "Public detail shows visibility PUBLIC" "PUBLIC" "$(json_field "$DETAIL" "data.visibility")" + +SUBMITTED="$(api GET "/api/web/authoring/drafts/$DRAFT_ID")" +assert_equals "Draft shows submitted skill id $SKILL_ID" "$SKILL_ID" "$(json_field "$SUBMITTED" "data.submittedSkillId")" + +# ---------------------------------------------------------------- summary + +echo +if [[ "$FAIL" -eq 0 ]]; then + echo "=== All $PASS checks passed — full chain: create → validate → submit → scan → review → publish ===" + exit 0 +fi +echo "=== $FAIL check(s) failed, $PASS passed ===" +exit 1 diff --git a/scripts/authoring-smoke-test.sh b/scripts/authoring-smoke-test.sh new file mode 100755 index 000000000..c5e84f33a --- /dev/null +++ b/scripts/authoring-smoke-test.sh @@ -0,0 +1,511 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Authoring platform live smoke test. +# +# Drives the full draft lifecycle over HTTP against a running SkillHub server: +# case 1 — happy path: create draft → edit files → bind runtime → validate +# (structure + config + behavior) → SSE replay → submit +# case 2 — fix loop: broken SKILL.md → FAILED run → apply suggested fix → +# revision bump → re-validate → SUCCEEDED +# case 3 — guards: submit without validation and cross-user access are rejected +# case 4 — MCP probe: dead MCP endpoint → MCP_CONNECT_FAILED finding; a local +# fake MCP server → tools discovered in the event log; unknown +# toolFilters → warning without failing the run +# case 5 — security guards: cloud-metadata endpoints and envRefs outside the +# allowlist are rejected at binding save time +# +# Requires the local dev profile (mock auth enabled). Usage: +# scripts/authoring-smoke-test.sh [base-url] + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +COOKIE="$(mktemp)" +RUN_ID="$(date +%s)" +FAKE_MCP_PID="" +FAKE_MCP_PORT_FILE="$(mktemp)" + +cleanup() { + [[ -n "$FAKE_MCP_PID" ]] && kill "$FAKE_MCP_PID" 2>/dev/null || true + rm -f "$COOKIE" "$FAKE_MCP_PORT_FILE" +} +trap cleanup EXIT + +pass() { + echo "PASS: $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "FAIL: $1" + FAIL=$((FAIL + 1)) +} + +csrf_token() { + awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE" | tail -n 1 +} + +bootstrap_csrf() { + curl -s -c "$COOKIE" -H "X-Mock-User-Id: local-user" "$BASE_URL/api/v1/auth/providers" >/dev/null +} + +json_field() { + local json="$1" + local expr="$2" + JSON_INPUT="$json" python3 - "$expr" <<'PY' +import json +import os +import sys + +expr = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +value = data +for part in expr.split('.'): + if part.isdigit(): + value = value[int(part)] + else: + value = value[part] +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +PY +} + +assert_code() { + local description="$1" + local json="$2" + local expected="$3" + local actual + actual="$(json_field "$json" "code")" + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description (expected code $expected, got $actual)" + fi +} + +assert_equals() { + local description="$1" + local expected="$2" + local actual="$3" + if [[ "$expected" == "$actual" ]]; then + pass "$description" + else + fail "$description (expected '$expected', got '$actual')" + fi +} + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + if [[ -n "$data" ]]; then + curl -sS -H "X-Mock-User-Id: local-user" -b "$COOKIE" -c "$COOKIE" \ + -H "X-XSRF-TOKEN: $CSRF" -H "Content-Type: application/json" \ + -X "$method" "$BASE_URL$path" -d "$data" + else + curl -sS -H "X-Mock-User-Id: local-user" -b "$COOKIE" -c "$COOKIE" \ + -H "X-XSRF-TOKEN: $CSRF" -X "$method" "$BASE_URL$path" + fi +} + +# Polls a run until terminal; echoes the terminal status. +await_run_terminal() { + local run_id="$1" + local status="" + for _ in $(seq 1 60); do + status="$(json_field "$(api GET "/api/web/authoring/runs/$run_id")" "data.status")" + case "$status" in + SUCCEEDED|FAILED|CANCELLED|TIMED_OUT) break ;; + esac + sleep 1 + done + echo "$status" +} + +echo "=== Authoring Platform Smoke Test ===" +echo "Target: $BASE_URL" +echo + +bootstrap_csrf +CSRF="$(csrf_token "$COOKIE")" +if [[ -z "$CSRF" ]]; then + echo "Could not bootstrap CSRF token (is mock auth enabled?)" + exit 1 +fi + +# ---------------------------------------------------------------- fixture namespace + +# Discover an existing namespace the user can author in (namespace creation is +# admin-gated on the dev profile, so the smoke test reuses "global" or any +# namespace the mock user already owns). +NS_SLUG="$(api GET /api/web/me/namespaces | python3 -c 'import json,os,sys; data=json.loads(sys.stdin.read())["data"]; print(data[0]["slug"] if data else "")')" +if [[ -n "$NS_SLUG" ]]; then + pass "Authoring namespace available: $NS_SLUG" +else + fail "No accessible namespace for the mock user" + echo "=== $FAIL check(s) failed ===" + exit 1 +fi +SLUG="$NS_SLUG" + +# ---------------------------------------------------------------- case 1: happy path + +echo +echo "--- Case 1: draft validates across all layers and submits ---" + +# Draft names are unique per run so the smoke test is re-runnable against a +# shared dev database. +NAME="smoke-skill-$RUN_ID" +DRAFT_RESPONSE="$(api POST /api/web/authoring/drafts \ + "{\"namespaceSlug\":\"$SLUG\",\"name\":\"$NAME\",\"requirement\":\"smoke test skill\"}")" +assert_code "Draft created with scaffold" "$DRAFT_RESPONSE" "0" +DRAFT_ID="$(json_field "$DRAFT_RESPONSE" "data.id")" + +SKILL_MD="--- +name: $NAME +description: greets the caller with a fixed message +--- + +# Smoke skill + +## Overview + +greets the caller with a fixed message +" +FILES_RESPONSE="$(api PUT "/api/web/authoring/drafts/$DRAFT_ID/files" \ + "{\"path\":\"SKILL.md\",\"content\":$(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<< "$SKILL_MD"),\"contentType\":\"text/markdown\"}")" +assert_code "SKILL.md saved" "$FILES_RESPONSE" "0" + +SCRIPT_RESPONSE="$(api PUT "/api/web/authoring/drafts/$DRAFT_ID/files" \ + '{"path":"scripts/greet.sh","content":"echo hello from smoke\n"}')" +assert_code "Script file saved" "$SCRIPT_RESPONSE" "0" + +VALIDATION_YAML='version: 1 +tasks: + - name: greet + description: script prints the greeting + type: script + script: scripts/greet.sh + args: [] + timeoutMs: 10000 + assertions: + - type: exit_code + equals: 0 + - type: stdout_contains + value: hello from smoke +' +SPEC_RESPONSE="$(api PUT "/api/web/authoring/drafts/$DRAFT_ID/files" \ + "{\"path\":\"validation.yaml\",\"content\":$(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<< "$VALIDATION_YAML")}")" +assert_code "validation.yaml saved" "$SPEC_RESPONSE" "0" + +BINDING_RESPONSE="$(api PUT "/api/web/authoring/drafts/$DRAFT_ID/runtime" \ + '{"agentType":"local-script","config":{"interpreter":"sh"},"toolAllowlist":[],"mcpServers":[]}')" +assert_code "Runtime binding saved" "$BINDING_RESPONSE" "0" + +RUN_RESPONSE="$(api POST "/api/web/authoring/drafts/$DRAFT_ID/runs")" +assert_code "Validation run started" "$RUN_RESPONSE" "0" +RUN_ID="$(json_field "$RUN_RESPONSE" "data.id")" + +RUN_STATUS="$(await_run_terminal "$RUN_ID")" +assert_equals "Run reaches SUCCEEDED" "SUCCEEDED" "$RUN_STATUS" + +FINDINGS_RESPONSE="$(api GET "/api/web/authoring/runs/$RUN_ID/findings")" +assert_code "Findings readable" "$FINDINGS_RESPONSE" "0" +FINDING_COUNT="$(JSON_INPUT="$FINDINGS_RESPONSE" python3 -c 'import json,os; print(len(json.loads(os.environ["JSON_INPUT"])["data"]))')" +assert_equals "Zero findings on valid draft" "0" "$FINDING_COUNT" + +EVENTS_RESPONSE="$(api GET "/api/web/authoring/runs/$RUN_ID/events")" +assert_code "Events readable (polling API)" "$EVENTS_RESPONSE" "0" +EVENT_COUNT="$(JSON_INPUT="$EVENTS_RESPONSE" python3 -c 'import json,os; print(len(json.loads(os.environ["JSON_INPUT"])["data"]))')" +if [[ "$EVENT_COUNT" -gt 4 ]]; then + pass "Run produced $EVENT_COUNT events" +else + fail "Expected more than 4 events, got $EVENT_COUNT" +fi + +SSE_OUTPUT="$(curl -sN --max-time 10 -H "X-Mock-User-Id: local-user" -b "$COOKIE" \ + "$BASE_URL/api/web/authoring/runs/$RUN_ID/events/stream" || true)" +if grep -q "RUN_STARTED" <<< "$SSE_OUTPUT" && grep -q "RUN_FINISHED" <<< "$SSE_OUTPUT"; then + pass "SSE stream replays all events and terminates" +else + fail "SSE stream did not replay the full run" +fi + +DRAFT_AFTER="$(api GET "/api/web/authoring/drafts/$DRAFT_ID")" +assert_equals "Draft marked validated" "True" "$(json_field "$DRAFT_AFTER" "data.validated")" + +SUBMIT_RESPONSE="$(api POST "/api/web/authoring/drafts/$DRAFT_ID/submit" '{"visibility":"PRIVATE"}')" +assert_code "Validated draft submits to publish pipeline" "$SUBMIT_RESPONSE" "0" +SKILL_ID="$(json_field "$SUBMIT_RESPONSE" "data.skillId")" +if [[ "$SKILL_ID" != "None" && -n "$SKILL_ID" ]]; then + pass "Submit returned skill id $SKILL_ID" +else + fail "Submit did not return a skill id" +fi + +# ---------------------------------------------------------------- case 2: fix loop + +echo +echo "--- Case 2: broken draft gets a fixable finding and revalidates ---" + +BROKEN_NAME="broken-skill-$RUN_ID" +BROKEN_RESPONSE="$(api POST /api/web/authoring/drafts \ + "{\"namespaceSlug\":\"$SLUG\",\"name\":\"$BROKEN_NAME\"}")" +assert_code "Broken draft created" "$BROKEN_RESPONSE" "0" +BROKEN_ID="$(json_field "$BROKEN_RESPONSE" "data.id")" + +BROKEN_MD="--- +name: $BROKEN_NAME +--- + +# Broken +" +api PUT "/api/web/authoring/drafts/$BROKEN_ID/files" \ + "{\"path\":\"SKILL.md\",\"content\":$(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<< "$BROKEN_MD"),\"contentType\":\"text/markdown\"}" >/dev/null + +BROKEN_RUN="$(api POST "/api/web/authoring/drafts/$BROKEN_ID/runs")" +BROKEN_RUN_ID="$(json_field "$BROKEN_RUN" "data.id")" +assert_equals "Broken draft fails validation" "FAILED" "$(await_run_terminal "$BROKEN_RUN_ID")" + +BROKEN_FINDINGS="$(api GET "/api/web/authoring/runs/$BROKEN_RUN_ID/findings")" +FINDING_ID="$(JSON_INPUT="$BROKEN_FINDINGS" python3 -c ' +import json, os +findings = json.loads(os.environ["JSON_INPUT"])["data"] +match = next((f for f in findings if f["ruleCode"] == "FRONTMATTER_FIELD_MISSING"), None) +print(match["id"] if match else "none")')" +if [[ "$FINDING_ID" != "none" ]]; then + pass "FRONTMATTER_FIELD_MISSING finding reported with location" +else + fail "Expected FRONTMATTER_FIELD_MISSING finding" +fi + +REVISION_BEFORE="$(json_field "$(api GET "/api/web/authoring/drafts/$BROKEN_ID")" "data.revision")" +APPLY_RESPONSE="$(api POST "/api/web/authoring/runs/$BROKEN_RUN_ID/findings/$FINDING_ID/apply")" +assert_code "Suggested fix applied" "$APPLY_RESPONSE" "0" +REVISION_AFTER="$(json_field "$(api GET "/api/web/authoring/drafts/$BROKEN_ID")" "data.revision")" +if [[ "$REVISION_AFTER" -gt "$REVISION_BEFORE" ]]; then + pass "Applying fix advanced revision $REVISION_BEFORE → $REVISION_AFTER" +else + fail "Applying fix did not advance the revision" +fi + +FIXED_RUN="$(api POST "/api/web/authoring/drafts/$BROKEN_ID/runs")" +FIXED_RUN_ID="$(json_field "$FIXED_RUN" "data.id")" +assert_equals "Re-validation after fix succeeds" "SUCCEEDED" "$(await_run_terminal "$FIXED_RUN_ID")" + +# ---------------------------------------------------------------- case 3: guards + +echo +echo "--- Case 3: guards ---" + +UNVALIDATED_RESPONSE="$(api POST /api/web/authoring/drafts \ + "{\"namespaceSlug\":\"$SLUG\",\"name\":\"unvalidated-skill-$RUN_ID\"}")" +UNVALIDATED_ID="$(json_field "$UNVALIDATED_RESPONSE" "data.id")" +SUBMIT_UNVALIDATED="$(api POST "/api/web/authoring/drafts/$UNVALIDATED_ID/submit" '{"visibility":"PRIVATE"}')" +if [[ "$(json_field "$SUBMIT_UNVALIDATED" "code")" != "0" ]]; then + pass "Submit rejected for unvalidated draft" +else + fail "Submit should be rejected for an unvalidated draft" +fi + +OTHER_USER_COOKIE="$(mktemp)" +# A plain second user (no admin roles): local-admin holds SUPER_ADMIN in dev data +# and platform admins are allowed to read any draft by design. +curl -s -c "$OTHER_USER_COOKIE" -H "X-Mock-User-Id: local-plain-user" "$BASE_URL/api/v1/auth/providers" >/dev/null +OTHER_CSRF="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$OTHER_USER_COOKIE" | tail -n 1)" +CROSS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-plain-user" -b "$OTHER_USER_COOKIE" \ + -H "X-XSRF-TOKEN: $OTHER_CSRF" "$BASE_URL/api/web/authoring/drafts/$DRAFT_ID")" +if [[ "$(json_field "$CROSS_RESPONSE" "code")" != "0" ]]; then + pass "Cross-user draft access rejected" +else + fail "Another user should not read someone else's draft" +fi +rm -f "$OTHER_USER_COOKIE" + +# ---------------------------------------------------------------- case 4: MCP probe + +echo +echo "--- Case 4: declared MCP servers are probed for real ---" + +MCP_NAME="mcp-skill-$RUN_ID" +MCP_RESPONSE="$(api POST /api/web/authoring/drafts \ + "{\"namespaceSlug\":\"$SLUG\",\"name\":\"$MCP_NAME\"}")" +assert_code "MCP probe draft created" "$MCP_RESPONSE" "0" +MCP_DRAFT_ID="$(json_field "$MCP_RESPONSE" "data.id")" + +MCP_MD="--- +name: $MCP_NAME +description: declares MCP servers on its runtime binding +--- + +# MCP probe skill +" +api PUT "/api/web/authoring/drafts/$MCP_DRAFT_ID/files" \ + "{\"path\":\"SKILL.md\",\"content\":$(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<< "$MCP_MD"),\"contentType\":\"text/markdown\"}" >/dev/null + +# Negative: an endpoint nothing listens on must surface as a config-layer error. +DEAD_BINDING="$(api PUT "/api/web/authoring/drafts/$MCP_DRAFT_ID/runtime" \ + '{"agentType":"local-script","config":{"interpreter":"sh"},"toolAllowlist":[],"mcpServers":[{"name":"dead-server","transport":"http","endpoint":"http://127.0.0.1:9/mcp"}]}')" +assert_code "Binding with dead MCP server accepted" "$DEAD_BINDING" "0" + +DEAD_RUN_ID="$(json_field "$(api POST "/api/web/authoring/drafts/$MCP_DRAFT_ID/runs")" "data.id")" +assert_equals "Dead MCP server fails the run" "FAILED" "$(await_run_terminal "$DEAD_RUN_ID")" + +DEAD_FINDINGS="$(api GET "/api/web/authoring/runs/$DEAD_RUN_ID/findings")" +DEAD_RULE="$(JSON_INPUT="$DEAD_FINDINGS" python3 -c ' +import json, os +findings = json.loads(os.environ["JSON_INPUT"])["data"] +match = next((f for f in findings if f["ruleCode"] == "MCP_CONNECT_FAILED"), None) +print(match["ruleCode"] if match else "none")')" +assert_equals "MCP_CONNECT_FAILED finding reported" "MCP_CONNECT_FAILED" "$DEAD_RULE" + +# Positive: a local fake MCP server; the probe must connect and list its tools. +FAKE_MCP_SCRIPT=' +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + msg = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0)))) + method = msg.get("method", "") + if method == "initialize": + self.reply(msg, {"protocolVersion": "2024-11-05", "capabilities": {}, + "serverInfo": {"name": "smoke-mcp", "version": "1.0"}}, + session="smoke-session-1") + elif method == "tools/list": + self.reply(msg, {"tools": [{"name": "echo", "description": "echo text", + "inputSchema": {"type": "object"}}]}) + elif method == "tools/call": + self.reply(msg, {"content": [{"type": "text", "text": "ok"}]}) + else: + self.reply(msg, {}) + + def reply(self, msg, result, session=None): + body = json.dumps({"jsonrpc": "2.0", "id": msg.get("id"), "result": result}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + if session: + self.send_header("Mcp-Session-Id", session) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + +server = HTTPServer(("127.0.0.1", 0), Handler) +print(server.server_address[1], flush=True) +server.serve_forever() +' +python3 -c "$FAKE_MCP_SCRIPT" > "$FAKE_MCP_PORT_FILE" 2>/dev/null & +FAKE_MCP_PID=$! +MCP_PORT="" +for _ in $(seq 1 25); do + MCP_PORT="$(cat "$FAKE_MCP_PORT_FILE" 2>/dev/null || true)" + [[ -n "$MCP_PORT" ]] && break + sleep 0.2 +done +if [[ -n "$MCP_PORT" ]]; then + pass "Fake MCP server listening on port $MCP_PORT" +else + fail "Fake MCP server did not start" +fi + +LIVE_BINDING="$(api PUT "/api/web/authoring/drafts/$MCP_DRAFT_ID/runtime" \ + "{\"agentType\":\"local-script\",\"config\":{\"interpreter\":\"sh\"},\"toolAllowlist\":[],\"mcpServers\":[{\"name\":\"smoke-mcp\",\"transport\":\"http\",\"endpoint\":\"http://127.0.0.1:$MCP_PORT/mcp\",\"toolFilters\":[\"echo\"]}]}")" +assert_code "Binding with live MCP server accepted" "$LIVE_BINDING" "0" + +LIVE_RUN_ID="$(json_field "$(api POST "/api/web/authoring/drafts/$MCP_DRAFT_ID/runs")" "data.id")" +assert_equals "Live MCP server lets the run pass" "SUCCEEDED" "$(await_run_terminal "$LIVE_RUN_ID")" + +LIVE_EVENTS="$(api GET "/api/web/authoring/runs/$LIVE_RUN_ID/events")" +if grep -q "connected; tools: echo" <<< "$LIVE_EVENTS"; then + pass "Event log records discovered MCP tools" +else + fail "Event log lacks the MCP tools discovery line" +fi + +# toolFilters naming a tool the server does not expose is a warning, not an error. +FILTERED_BINDING="$(api PUT "/api/web/authoring/drafts/$MCP_DRAFT_ID/runtime" \ + "{\"agentType\":\"local-script\",\"config\":{\"interpreter\":\"sh\"},\"toolAllowlist\":[],\"mcpServers\":[{\"name\":\"smoke-mcp\",\"transport\":\"http\",\"endpoint\":\"http://127.0.0.1:$MCP_PORT/mcp\",\"toolFilters\":[\"echo\",\"does_not_exist\"]}]}")" +assert_code "Binding with unknown tool filter accepted" "$FILTERED_BINDING" "0" + +FILTERED_RUN_ID="$(json_field "$(api POST "/api/web/authoring/drafts/$MCP_DRAFT_ID/runs")" "data.id")" +assert_equals "Unknown tool filter still succeeds" "SUCCEEDED" "$(await_run_terminal "$FILTERED_RUN_ID")" + +FILTERED_FINDINGS="$(api GET "/api/web/authoring/runs/$FILTERED_RUN_ID/findings")" +FILTERED_RULE="$(JSON_INPUT="$FILTERED_FINDINGS" python3 -c ' +import json, os +findings = json.loads(os.environ["JSON_INPUT"])["data"] +match = next((f for f in findings if f["ruleCode"] == "MCP_TOOL_FILTER_UNKNOWN"), None) +print(match["ruleCode"] if match else "none")')" +assert_equals "MCP_TOOL_FILTER_UNKNOWN warning reported" "MCP_TOOL_FILTER_UNKNOWN" "$FILTERED_RULE" + +kill "$FAKE_MCP_PID" 2>/dev/null || true +wait "$FAKE_MCP_PID" 2>/dev/null || true +FAKE_MCP_PID="" + +# ---------------------------------------------------------------- case 5: security guards + +echo +echo "--- Case 5: binding security guards reject at save time ---" + +SEC_NAME="security-skill-$RUN_ID" +SEC_RESPONSE="$(api POST /api/web/authoring/drafts \ + "{\"namespaceSlug\":\"$SLUG\",\"name\":\"$SEC_NAME\"}")" +assert_code "Security guard draft created" "$SEC_RESPONSE" "0" +SEC_DRAFT_ID="$(json_field "$SEC_RESPONSE" "data.id")" + +SEC_MD="--- +name: $SEC_NAME +description: exercises the binding security policy +--- + +# Security guard skill +" +api PUT "/api/web/authoring/drafts/$SEC_DRAFT_ID/files" \ + "{\"path\":\"SKILL.md\",\"content\":$(python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' <<< "$SEC_MD"),\"contentType\":\"text/markdown\"}" >/dev/null + +# Cloud metadata endpoints are always blocked — even on the local profile where +# loopback/RFC1918 endpoints are deliberately allowed. Reason text assertions +# live in AuthoringFlowIntegrationTest; here we pin the save-time rejection. +METADATA_BINDING="$(api PUT "/api/web/authoring/drafts/$SEC_DRAFT_ID/runtime" \ + '{"agentType":"local-script","config":{"interpreter":"sh"},"toolAllowlist":[],"mcpServers":[{"name":"metadata-thief","transport":"http","endpoint":"http://169.254.169.254/latest/meta-data/"}]}')" +if [[ "$(json_field "$METADATA_BINDING" "code")" != "0" ]]; then + pass "Cloud metadata MCP endpoint rejected at save" +else + fail "Cloud metadata MCP endpoint must be rejected at save" +fi + +# envRefs outside the (empty) allowlist are rejected: bindings must not read +# arbitrary server environment variables. +ENVREF_BINDING="$(api PUT "/api/web/authoring/drafts/$SEC_DRAFT_ID/runtime" \ + '{"agentType":"local-script","config":{"interpreter":"sh"},"toolAllowlist":[],"mcpServers":[{"name":"env-hog","transport":"http","endpoint":"http://127.0.0.1:9/mcp","envRefs":["HOME"]}]}')" +if [[ "$(json_field "$ENVREF_BINDING" "code")" != "0" ]]; then + pass "envRefs outside the allowlist rejected at save" +else + fail "envRefs outside the allowlist must be rejected at save" +fi + +# The LLM surface is guarded too — those requests would carry the server API key. +LLM_BINDING="$(api PUT "/api/web/authoring/drafts/$SEC_DRAFT_ID/runtime" \ + '{"agentType":"openai-compatible","config":{"endpoint":"http://169.254.169.254/v1","model":"test-model"},"toolAllowlist":[],"mcpServers":[]}')" +if [[ "$(json_field "$LLM_BINDING" "code")" != "0" ]]; then + pass "Cloud metadata LLM endpoint rejected at save" +else + fail "Cloud metadata LLM endpoint must be rejected at save" +fi + +# ---------------------------------------------------------------- summary + +echo +if [[ "$FAIL" -eq 0 ]]; then + echo "=== All $PASS checks passed ===" + exit 0 +fi +echo "=== $FAIL check(s) failed, $PASS passed ===" +exit 1 diff --git a/scripts/riscv64-native-setup.sh b/scripts/riscv64-native-setup.sh new file mode 100755 index 000000000..a6c76c5be --- /dev/null +++ b/scripts/riscv64-native-setup.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +# One-shot native riscv64 verification setup for the SkillHub authoring +# platform. Runs ON the native riscv64 machine (e.g. a PLCT/INCHI RVLab +# board) after SSH access is granted: +# +# 1. installs Docker, PostgreSQL and Redis (Ubuntu/Debian via apt, +# openEuler via dnf); +# 2. creates the empty database the verification run migrates from zero +# (user skillhub / password skillhub_dev, matching the local profile); +# 3. opens PostgreSQL and Redis to the docker bridge so the server +# container can reach them (lab machine scope only); +# 4. builds the linux/riscv64 server image natively (no QEMU) — the +# Alpine JDK build stage has no riscv64 variant, so this passes the +# Noble one via --build-arg; +# 5. runs scripts/riscv64-verify.sh against the freshly built image and +# tees everything into an evidence log. +# +# Usage (from the skillhub checkout, as a user with sudo): +# sudo scripts/riscv64-native-setup.sh +# +# Evidence lands in riscv64-native-evidence-.log next to the script. +# Reviewers may additionally ask for: uname -a, /proc/cpuinfo head, and the +# absence of qemu strings in the boot log (native-proof items from +# docs/26-skill-authoring-platform.md). + +set -euo pipefail + +cd "$(dirname "$0")/.." +REPO_ROOT="$(pwd)" +DB_NAME="${SKILLHUB_RISCV_DB:-skillhub_riscv_check}" +BRIDGE_HOST="${SKILLHUB_RISCV_DB_HOST:-172.17.0.1}" +IMAGE="${SKILLHUB_RISCV_IMAGE:-skillhub-server:riscv64-native}" +EVIDENCE="riscv64-native-evidence-$(date +%Y%m%d-%H%M%S).log" + +log() { echo "$@" | tee -a "$EVIDENCE"; } + +[[ "$(uname -m)" == "riscv64" ]] || { echo "this script targets native riscv64 hosts; here uname -m is $(uname -m)" >&2; exit 1; } +[[ $EUID -eq 0 ]] || { echo "run with sudo (needs to install packages and edit service configs)" >&2; exit 1; } + +log "=== native riscv64 verification setup — $(date -u +%FT%TZ) ===" +log "host: $(uname -a)" +log "cpu: $(head -4 /proc/cpuinfo | tr '\n' ' ')" +if lsmod 2>/dev/null | grep -q qemu || grep -qi qemu /proc/cpuinfo; then + log "WARNING: qemu markers found — this may not be a native machine" +fi +log + +PKG="" +if command -v apt-get >/dev/null 2>&1; then + PKG=apt +elif command -v dnf >/dev/null 2>&1; then + PKG=dnf +else + echo "unsupported distro: need apt-get or dnf" >&2; exit 1 +fi +log "--- 1. install docker / postgresql / redis ($PKG) ---" +if [[ "$PKG" == "apt" ]]; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + apt-get install -y docker.io postgresql redis-server +else + dnf install -y docker postgresql-server redis || dnf install -y docker podman postgresql-server redis +fi +systemctl enable --now docker 2>/dev/null || service docker start 2>/dev/null || true +systemctl enable --now redis 2>/dev/null || systemctl enable --now redis-server 2>/dev/null || service redis-server start 2>/dev/null || true +log "docker: $(docker --version 2>&1)" + +log "--- 2. prepare empty database $DB_NAME (Flyway migrates from zero) ---" +if [[ "$PKG" == "apt" ]]; then + PG_CONF=/etc/postgresql/*/main +else + PG_CONF=/var/lib/pgsql/data +fi +# listen on all interfaces and accept the docker bridge with password auth +if [[ "$PKG" == "apt" ]]; then + sed -i "s/^#\?listen_addresses.*/listen_addresses = '*'/" $PG_CONF/postgresql.conf + grep -q "172.17.0.0/16" $PG_CONF/pg_hba.conf || echo "host all all 172.17.0.0/16 scram-sha-256" >> $PG_CONF/pg_hba.conf + systemctl reload postgresql 2>/dev/null || systemctl restart postgresql +else + postgresql-setup --initdb 2>/dev/null || true + sed -i "s/^#\?listen_addresses.*/listen_addresses = '*'/" $PG_CONF/postgresql.conf + grep -q "172.17.0.0/16" $PG_CONF/pg_hba.conf || echo "host all all 172.17.0.0/16 scram-sha-256" >> $PG_CONF/pg_hba.conf + systemctl enable --now postgresql +fi +sudo -u postgres psql -v ON_ERROR_STOP=1 </dev/null | head -1) +if [[ -n "$REDIS_CONF" ]]; then + sed -i 's/^#\?bind .*/bind 127.0.0.1 172.17.0.1/' "$REDIS_CONF" + sed -i 's/^#\?protected-mode .*/protected-mode no/' "$REDIS_CONF" + systemctl restart redis 2>/dev/null || systemctl restart redis-server 2>/dev/null || service redis-server restart 2>/dev/null || true + log "redis listening on 127.0.0.1 + $BRIDGE_HOST" +else + log "redis.conf not found automatically — ensure redis answers on $BRIDGE_HOST:6379" +fi + +log "--- 4. build the image natively (no QEMU) ---" +docker build --build-arg BUILD_IMAGE=eclipse-temurin:21-jdk-noble \ + -t "$IMAGE" server/ 2>&1 | tee -a "$EVIDENCE" | tail -5 +docker image inspect "$IMAGE" --format 'built: {{.Os}}/{{.Architecture}}' | tee -a "$EVIDENCE" + +log "--- 5. run the authoring verification ---" +SKILLHUB_RISCV_DB="$DB_NAME" SKILLHUB_RISCV_DB_HOST="$BRIDGE_HOST" \ + bash scripts/riscv64-verify.sh "$IMAGE" 2>&1 | tee -a "$EVIDENCE" + +log +log "=== done — evidence saved to $EVIDENCE ===" diff --git a/scripts/riscv64-verify.sh b/scripts/riscv64-verify.sh new file mode 100755 index 000000000..03ef15cd4 --- /dev/null +++ b/scripts/riscv64-verify.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash + +# RISC-V64 live verification of the authoring platform. +# +# Boots the linux/riscv64 server (native on riscv64 hosts, QEMU-emulated +# elsewhere) against a dedicated PostgreSQL database and drives the full +# authoring flow over the REST API: +# create draft → read scaffold → save script + validation.yaml → bind runtime +# → validate → SUCCEEDED → script stdout visible in the event stream. +# +# Boot modes (SKILLHUB_RISCV_BOOT_MODE, default docker): +# docker — run the linux/riscv64 image (see "Building the image" below); +# jar — run an already-built jar directly with java (no Docker needed; +# used by the real-board CI workflow, where the build itself proves +# the toolchain and PG/Redis run as services on the board). +# +# The script assumes: +# - docker mode: the image exists; PostgreSQL and Redis are reachable from +# the container (defaults use host.docker.internal on ports 5432/6379); +# - jar mode: SKILLHUB_RISCV_JAR exists (default +# server/skillhub-app/target/skillhub-app-0.1.0.jar) and PG/Redis answer +# on localhost; +# - the DB given by SKILLHUB_RISCV_DB is EMPTY — Flyway migrates it from zero +# (the script never touches your main dev database). +# +# Usage: +# scripts/riscv64-verify.sh [image] # default skillhub-server:riscv64 +# SKILLHUB_RISCV_DB=mydb scripts/riscv64-verify.sh myimage:tag +# SKILLHUB_RISCV_BOOT_MODE=jar SKILLHUB_RISCV_DB=mydb scripts/riscv64-verify.sh +# +# Building the image (on any host; buildx + QEMU required on non-riscv64): +# cd server && docker buildx build --platform linux/riscv64 \ +# -t skillhub-server:riscv64 --load . +# On a native riscv64 host pass --build-arg BUILD_IMAGE=eclipse-temurin:21-jdk-noble +# (the default Alpine JDK build stage has no riscv64 variant). +# +# Native hardware note: on a real riscv64 host (uname -m == riscv64) the same +# script runs without emulation; the log line marks which mode you are in. +# QEMU mode proves architecture independence of the JVM workload, native mode +# additionally proves the platform services' riscv64 images. + +set -u + +IMAGE="${1:-skillhub-server:riscv64}" +DB_NAME="${SKILLHUB_RISCV_DB:-skillhub_riscv_check}" +MODE="${SKILLHUB_RISCV_BOOT_MODE:-docker}" +JAR="${SKILLHUB_RISCV_JAR:-server/skillhub-app/target/skillhub-app-0.1.0.jar}" +HOST="${SKILLHUB_RISCV_DB_HOST:-$([[ "$MODE" == "jar" ]] && echo localhost || echo host.docker.internal)}" +CONTAINER="skillhub-riscv-check" +SERVER_PID="" +SERVER_LOG="/tmp/skillhub-riscv-verify.log" +BASE_URL="http://localhost:18082" +COOKIE="$(mktemp)" +PASS=0 +FAIL=0 + +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } + +json_field() { + JSON_INPUT="$1" python3 - "$2" <<'PY' +import json, os, sys +expr = sys.argv[1] +value = json.loads(os.environ["JSON_INPUT"]) +for part in expr.split('.'): + value = value[int(part)] if part.isdigit() else value[part] +print(json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else value) +PY +} + +csrf_token() { awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE" | tail -n 1; } +bootstrap_csrf() { + curl -s -c "$COOKIE" -H "X-Mock-User-Id: local-user" "$BASE_URL/api/v1/auth/providers" >/dev/null +} +api() { + local method="$1" + local path="$2" + local body="${3:-}" + if [[ -n "$body" ]]; then + curl -s -b "$COOKIE" -c "$COOKIE" -X "$method" -H "Content-Type: application/json" \ + -H "X-Mock-User-Id: local-user" -H "X-XSRF-TOKEN: $(csrf_token)" -d "$body" "$BASE_URL$path" + else + curl -s -b "$COOKIE" -c "$COOKIE" -X "$method" \ + -H "X-Mock-User-Id: local-user" -H "X-XSRF-TOKEN: $(csrf_token)" "$BASE_URL$path" + fi +} + +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + kill "$SERVER_PID" >/dev/null 2>&1 || true + fi + if [[ "$MODE" == "docker" ]]; then + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + fi + rm -f "$COOKIE" +} +trap cleanup EXIT + +echo "=== RISC-V64 authoring platform verification ===" +if [[ "$MODE" == "docker" ]]; then + echo "image: $IMAGE" +else + echo "jar: $JAR" +fi +echo "database: $DB_NAME on $HOST" +ARCH="$(uname -m)" +if [[ "$ARCH" == "riscv64" ]]; then + echo "mode: NATIVE riscv64 host" +else + echo "mode: QEMU emulation (host arch $ARCH)" +fi +echo + +echo "--- 1. boot the server (boot mode: $MODE) ---" +if [[ "$MODE" == "docker" ]]; then + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + docker run -d --name "$CONTAINER" -p 18082:8080 "$IMAGE" \ + --spring.profiles.active=local \ + --spring.datasource.url="jdbc:postgresql://$HOST:5432/$DB_NAME" \ + --spring.data.redis.host="$HOST" >/dev/null || { echo "docker run failed"; exit 1; } + IMAGE_ARCH="$(docker inspect "$CONTAINER" --format '{{.Image}}' >/dev/null 2>&1 && docker exec "$CONTAINER" uname -m 2>/dev/null || echo unknown)" +else + [[ -f "$JAR" ]] || { echo "jar not found at $JAR — build it or set SKILLHUB_RISCV_JAR"; exit 1; } + java -jar "$JAR" --spring.profiles.active=local --server.port=18082 \ + --spring.datasource.url="jdbc:postgresql://$HOST:5432/$DB_NAME" \ + --spring.data.redis.host="$HOST" > "$SERVER_LOG" 2>&1 & + SERVER_PID=$! + echo "server pid $SERVER_PID (log: $SERVER_LOG)" + IMAGE_ARCH="$(uname -m)" +fi +echo "arch the server runs on: $IMAGE_ARCH" +if [[ "$IMAGE_ARCH" == "riscv64" ]]; then + pass "server userland is riscv64" +else + fail "server userland is '$IMAGE_ARCH', expected riscv64" +fi + +HEALTHY=0 +for i in $(seq 1 90); do + code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/actuator/health" 2>/dev/null || true) + if [[ "$code" == "200" ]]; then + echo "health 200 after ~$((i * 10))s" + HEALTHY=1 + break + fi + if [[ "$MODE" == "docker" ]]; then + if ! docker ps --format '{{.Names}}' | grep -q "^$CONTAINER$"; then + echo "CONTAINER DIED — last logs:" + docker logs "$CONTAINER" 2>&1 | tail -30 + exit 1 + fi + else + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "SERVER DIED — last logs:" + tail -30 "$SERVER_LOG" + exit 1 + fi + fi + sleep 10 +done +if [[ "$HEALTHY" == "1" ]]; then + pass "server boots and becomes healthy" +else + fail "server did not become healthy in 900s" + if [[ "$MODE" == "docker" ]]; then + docker logs "$CONTAINER" 2>&1 | tail -30 + else + tail -30 "$SERVER_LOG" + fi + exit 1 +fi + +echo "--- 2. authoring flow over the API ---" +bootstrap_csrf +RUN_TAG="$(date +%s)" +DRAFT="$(api POST /api/web/authoring/drafts \ + "{\"namespaceSlug\":\"global\",\"name\":\"riscv-verify-$RUN_TAG\",\"description\":\"verifies the authoring platform on riscv64\"}")" +if [[ "$(json_field "$DRAFT" "code")" == "0" ]]; then + pass "draft created" +else + fail "draft create: $DRAFT" + exit 1 +fi +DRAFT_ID="$(json_field "$DRAFT" "data.id")" + +SCAFFOLD="$(api GET "/api/web/authoring/drafts/$DRAFT_ID/files/content?path=SKILL.md")" +if echo "$SCAFFOLD" | grep -q "name: riscv-verify-$RUN_TAG"; then + pass "scaffold SKILL.md persisted and readable" +else + fail "scaffold read: $SCAFFOLD" +fi + +api PUT "/api/web/authoring/drafts/$DRAFT_ID/files" \ + '{"path":"scripts/check.sh","content":"#!/bin/sh\necho riscv-script-OK\n","contentType":"text/x-shellscript"}' >/dev/null +# generous task timeout: first docker execution-mode runs may pull the +# alpine:3.20 riscv64 image before the script can start +api PUT "/api/web/authoring/drafts/$DRAFT_ID/files" \ + '{"path":"validation.yaml","content":"version: 1\ntasks:\n - name: smoke\n description: script runs on riscv64\n type: script\n script: scripts/check.sh\n args: []\n timeoutMs: 180000\n assertions:\n - type: exit_code\n equals: 0\n - type: stdout_contains\n value: riscv-script-OK\n"}' >/dev/null + +BINDING="$(api PUT "/api/web/authoring/drafts/$DRAFT_ID/runtime" \ + '{"agentType":"local-script","config":{"interpreter":"sh"},"toolAllowlist":[],"mcpServers":[]}')" +if [[ "$(json_field "$BINDING" "code")" == "0" ]]; then + pass "runtime binding saved" +else + fail "binding: $BINDING" +fi + +RUN="$(api POST "/api/web/authoring/drafts/$DRAFT_ID/runs" '{}')" +RUN_ID="$(json_field "$RUN" "data.id")" + +STATUS="" +for _ in $(seq 1 60); do + STATUS="$(json_field "$(api GET "/api/web/authoring/runs/$RUN_ID")" "data.status")" + case "$STATUS" in + SUCCEEDED|FAILED|CANCELLED|TIMED_OUT) break ;; + esac + sleep 5 +done +if [[ "$STATUS" == "SUCCEEDED" ]]; then + pass "validation run SUCCEEDED (script executed in the server's riscv64 userland)" +else + fail "validation run ended as $STATUS" + # surface the findings so failures are diagnosable from the job log alone + FINDINGS="$(api GET "/api/web/authoring/runs/$RUN_ID/findings")" + JSON_INPUT="$FINDINGS" python3 -c ' +import json, os +for f in json.loads(os.environ["JSON_INPUT"])["data"]: + print(" finding: [%s/%s] %s" % (f.get("layer"), f.get("ruleCode"), f.get("message")))' 2>/dev/null || true +fi + +EVENTS="$(api GET "/api/web/authoring/runs/$RUN_ID/events")" +EVENT_COUNT="$(JSON_INPUT="$EVENTS" python3 -c ' +import json, os +print(len(json.loads(os.environ["JSON_INPUT"])["data"]))' 2>/dev/null || echo 0)" +if echo "$EVENTS" | grep -q "riscv-script-OK"; then + pass "script stdout present in the event stream ($EVENT_COUNT events)" +else + fail "script stdout missing from the event stream" +fi + +echo +if [[ "$FAIL" -eq 0 ]]; then + echo "=== All $PASS checks passed ===" + exit 0 +fi +echo "=== $FAIL check(s) failed, $PASS passed ===" +exit 1 diff --git a/server/Dockerfile b/server/Dockerfile index 801ced808..16a8c659b 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,6 +1,9 @@ # Build the architecture-neutral JAR on the Buildx host. This avoids emulating # the complete Maven build when the target image is linux/riscv64. -FROM --platform=$BUILDPLATFORM eclipse-temurin:21-jdk-alpine AS build +# On a native riscv64 host pass --build-arg BUILD_IMAGE=eclipse-temurin:21-jdk-noble +# (the Alpine JDK image has no riscv64 variant; the Noble one does). +ARG BUILD_IMAGE=eclipse-temurin:21-jdk-alpine +FROM --platform=$BUILDPLATFORM ${BUILD_IMAGE} AS build WORKDIR /app # Cache dependencies diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java index 71ee7de8c..56540ea71 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub; import com.iflytek.skillhub.bootstrap.BuiltinSkillProperties; +import com.iflytek.skillhub.config.AuthoringProperties; import com.iflytek.skillhub.config.ProfileFieldPolicyProperties; import com.iflytek.skillhub.config.ProfileModerationProperties; import org.springframework.boot.SpringApplication; @@ -14,7 +15,8 @@ @EnableConfigurationProperties({ BuiltinSkillProperties.class, ProfileModerationProperties.class, - ProfileFieldPolicyProperties.class + ProfileFieldPolicyProperties.class, + AuthoringProperties.class }) public class SkillhubApplication { public static void main(String[] args) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringExecutorConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringExecutorConfig.java new file mode 100644 index 000000000..79771dfa5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringExecutorConfig.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * Dedicated executor for authoring validation runs. Kept separate from + * {@code skillhubEventExecutor} because validation tasks run short-lived subprocesses + * and blocking HTTP calls that would starve the lightweight event executor. + */ +@Configuration +public class AuthoringExecutorConfig { + + @Bean(name = "authoringValidationExecutor") + public Executor authoringValidationExecutor(AuthoringProperties properties) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(Math.max(1, properties.getExecutorThreads())); + executor.setMaxPoolSize(Math.max(1, properties.getExecutorThreads())); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("authoring-validation-"); + // a full queue runs the task on the submitting thread instead of dropping runs + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java new file mode 100644 index 000000000..386128b78 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java @@ -0,0 +1,337 @@ +package com.iflytek.skillhub.config; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Feature configuration for the skill authoring and validation platform + * ({@code skillhub.authoring.*}). + */ +@ConfigurationProperties(prefix = "skillhub.authoring") +public class AuthoringProperties { + + /** Root directory for per-run isolated working directories. */ + private String workspaceRoot = System.getProperty("java.io.tmpdir") + "/skillhub-authoring"; + + /** Concurrent validation runs executed in-process. */ + private int executorThreads = 4; + + /** Hard wall-clock cap for one whole validation run. */ + private long runTimeoutMs = 900_000; + + /** Active runs older than this are swept to TIMED_OUT by the maintenance task. */ + private int staleRunMinutes = 30; + + private final LocalScript localScript = new LocalScript(); + + private final OpenAiCompatible openAiCompatible = new OpenAiCompatible(); + + private final Mcp mcp = new Mcp(); + + public String getWorkspaceRoot() { + return workspaceRoot; + } + + public void setWorkspaceRoot(String workspaceRoot) { + this.workspaceRoot = workspaceRoot; + } + + public int getExecutorThreads() { + return executorThreads; + } + + public void setExecutorThreads(int executorThreads) { + this.executorThreads = executorThreads; + } + + public long getRunTimeoutMs() { + return runTimeoutMs; + } + + public void setRunTimeoutMs(long runTimeoutMs) { + this.runTimeoutMs = runTimeoutMs; + } + + public int getStaleRunMinutes() { + return staleRunMinutes; + } + + public void setStaleRunMinutes(int staleRunMinutes) { + this.staleRunMinutes = staleRunMinutes; + } + + public LocalScript getLocalScript() { + return localScript; + } + + public OpenAiCompatible getOpenAiCompatible() { + return openAiCompatible; + } + + public Mcp getMcp() { + return mcp; + } + + /** Local script execution runtime. */ + public static class LocalScript { + private boolean enabled = true; + + /** Where scripts run: INLINE on the server host (dev), DOCKER in a locked-down container (production). */ + private ScriptExecutionMode executionMode = ScriptExecutionMode.INLINE; + + private final Docker docker = new Docker(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public ScriptExecutionMode getExecutionMode() { + return executionMode; + } + + public void setExecutionMode(ScriptExecutionMode executionMode) { + this.executionMode = executionMode; + } + + public Docker getDocker() { + return docker; + } + + /** Container isolation profile for the docker execution mode. */ + public static class Docker { + private String image = "alpine:3.20"; + private String memory = "256m"; + private String cpus = "1.0"; + private int pidsLimit = 128; + private String tmpfsSize = "64m"; + + public String getImage() { + return image; + } + + public void setImage(String image) { + this.image = image; + } + + public String getMemory() { + return memory; + } + + public void setMemory(String memory) { + this.memory = memory; + } + + public String getCpus() { + return cpus; + } + + public void setCpus(String cpus) { + this.cpus = cpus; + } + + public int getPidsLimit() { + return pidsLimit; + } + + public void setPidsLimit(int pidsLimit) { + this.pidsLimit = pidsLimit; + } + + public String getTmpfsSize() { + return tmpfsSize; + } + + public void setTmpfsSize(String tmpfsSize) { + this.tmpfsSize = tmpfsSize; + } + } + } + + /** Script execution backend selection. */ + public enum ScriptExecutionMode { + INLINE, + DOCKER + } + + /** + * OpenAI-compatible chat completion runtime. The API key is resolved here + * (environment/config), never from a per-draft runtime binding. + */ + public static class OpenAiCompatible { + private boolean enabled = false; + private String apiKey; + private String defaultEndpoint; + private String defaultModel; + private int timeoutMs = 120_000; + /** Max chat-completion rounds per prompt task (each round may execute MCP tool calls). */ + private int maxToolRounds = 4; + /** + * SSRF guard escape hatch for enterprise deployments whose LLM endpoint + * lives on a private network (e.g. an internal vLLM). Loopback and + * RFC1918 destinations stay blocked by default; link-local (cloud + * metadata) is always blocked. + */ + private boolean allowPrivateEndpoints = false; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getDefaultEndpoint() { + return defaultEndpoint; + } + + public void setDefaultEndpoint(String defaultEndpoint) { + this.defaultEndpoint = defaultEndpoint; + } + + public String getDefaultModel() { + return defaultModel; + } + + public void setDefaultModel(String defaultModel) { + this.defaultModel = defaultModel; + } + + public int getTimeoutMs() { + return timeoutMs; + } + + public void setTimeoutMs(int timeoutMs) { + this.timeoutMs = timeoutMs; + } + + public int getMaxToolRounds() { + return maxToolRounds; + } + + public void setMaxToolRounds(int maxToolRounds) { + this.maxToolRounds = maxToolRounds; + } + + public boolean isAllowPrivateEndpoints() { + return allowPrivateEndpoints; + } + + public void setAllowPrivateEndpoints(boolean allowPrivateEndpoints) { + this.allowPrivateEndpoints = allowPrivateEndpoints; + } + } + + /** + * Security knobs for user-declared MCP servers. Defaults are the safe + * production posture: private endpoints blocked, stdio transport off, no + * environment references allowed. Local development relaxes them via + * application-local.yml. + */ + public static class Mcp { + /** + * When false (default), http/sse MCP endpoints pointing at loopback, + * RFC1918, ULA, or CGNAT addresses are rejected. Link-local addresses + * (cloud metadata) are always rejected. + */ + private boolean allowPrivateEndpoints = false; + + /** When false (default), stdio MCP servers cannot be declared or spawned. */ + private boolean stdioEnabled = false; + + /** Server environment variables a binding may reference via envRefs. */ + private List envAllowlist = new ArrayList<>(); + + private final Docker docker = new Docker(); + + public boolean isAllowPrivateEndpoints() { + return allowPrivateEndpoints; + } + + public void setAllowPrivateEndpoints(boolean allowPrivateEndpoints) { + this.allowPrivateEndpoints = allowPrivateEndpoints; + } + + public boolean isStdioEnabled() { + return stdioEnabled; + } + + public void setStdioEnabled(boolean stdioEnabled) { + this.stdioEnabled = stdioEnabled; + } + + public List getEnvAllowlist() { + return envAllowlist; + } + + public void setEnvAllowlist(List envAllowlist) { + this.envAllowlist = envAllowlist; + } + + public Docker getDocker() { + return docker; + } + + /** Isolation profile for stdio MCP servers in docker execution mode. */ + public static class Docker { + private String image = "alpine:3.20"; + private String memory = "256m"; + private String cpus = "1.0"; + private int pidsLimit = 128; + private String tmpfsSize = "64m"; + + public String getImage() { + return image; + } + + public void setImage(String image) { + this.image = image; + } + + public String getMemory() { + return memory; + } + + public void setMemory(String memory) { + this.memory = memory; + } + + public String getCpus() { + return cpus; + } + + public void setCpus(String cpus) { + this.cpus = cpus; + } + + public int getPidsLimit() { + return pidsLimit; + } + + public void setPidsLimit(int pidsLimit) { + this.pidsLimit = pidsLimit; + } + + public String getTmpfsSize() { + return tmpfsSize; + } + + public void setTmpfsSize(String tmpfsSize) { + this.tmpfsSize = tmpfsSize; + } + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringStartupGuard.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringStartupGuard.java new file mode 100644 index 000000000..47eb0e36a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringStartupGuard.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.config; + +import java.util.Arrays; +import java.util.Set; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +/** + * Fails application startup when user-authored code would run directly on the + * host outside a development profile: with {@code skillhub.authoring.local-script} + * enabled in {@code INLINE} execution mode, validation scripts and stdio MCP + * commands execute as server subprocesses. Production deployments must use the + * {@code DOCKER} execution mode (or disable local-script entirely). + */ +@Component +public class AuthoringStartupGuard implements ApplicationRunner { + + /** Profiles in which host-side execution is a deliberate development choice. */ + private static final Set NON_PRODUCTION_PROFILES = Set.of("local", "dev", "test"); + + private final AuthoringProperties properties; + private final Environment environment; + + public AuthoringStartupGuard(AuthoringProperties properties, Environment environment) { + this.properties = properties; + this.environment = environment; + } + + @Override + public void run(ApplicationArguments args) { + if (!properties.getLocalScript().isEnabled()) { + return; + } + if (properties.getLocalScript().getExecutionMode() + != AuthoringProperties.ScriptExecutionMode.INLINE) { + return; + } + boolean nonProduction = Arrays.stream(environment.getActiveProfiles()) + .anyMatch(NON_PRODUCTION_PROFILES::contains); + if (nonProduction) { + return; + } + throw new IllegalStateException(""" + skillhub.authoring.local-script.execution-mode=inline is not allowed in \ + production: user-authored scripts and stdio MCP commands would run \ + directly on the host. Set SKILLHUB_AUTHORING_LOCAL_SCRIPT_MODE=docker \ + (locked-down containers) or disable local-script."""); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicy.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicy.java new file mode 100644 index 000000000..02380113e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicy.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.config; + +import com.iflytek.skillhub.domain.authoring.service.AuthoringSecurityPolicy; +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * The production {@link AuthoringSecurityPolicy}, derived from + * {@code skillhub.authoring.*} configuration. + * + *

SSRF posture: the server only makes outbound requests to public + * addresses. Link-local addresses (which include the 169.254.169.254 cloud + * metadata service), multicast, and the unspecified address are always + * rejected. Loopback, RFC1918, unique-local IPv6, CGNAT, and the RFC 2544 + * benchmark range (198.18.0.0/15) are rejected unless the surface-specific + * {@code allow-private-endpoints} flag is set (used by local development to + * reach loopback fake servers). + * + *

Known limitation: the guard resolves the hostname at check time; the + * HttpClient re-resolves at connect time, which mitigates (but cannot fully + * eliminate) DNS-rebinding races. Binding the socket to the checked address + * is not implemented because it would break virtual-hosted MCP endpoints. + */ +@Component +public class ConfiguredAuthoringSecurityPolicy implements AuthoringSecurityPolicy { + + /** Resolves a host to addresses; the default delegates to the JDK resolver. */ + @FunctionalInterface + interface HostResolver { + InetAddress[] resolve(String host) throws UnknownHostException; + } + + private final AuthoringProperties properties; + private final HostResolver resolver; + + @Autowired + public ConfiguredAuthoringSecurityPolicy(AuthoringProperties properties) { + this(properties, InetAddress::getAllByName); + } + + /** Test seam: injects a deterministic resolver so tests need no network. */ + ConfiguredAuthoringSecurityPolicy(AuthoringProperties properties, HostResolver resolver) { + this.properties = properties; + this.resolver = resolver; + } + + @Override + public String endpointRejection(String url, EndpointUse use) { + URI uri; + try { + uri = URI.create(url); + } catch (IllegalArgumentException exception) { + return "malformed URL: " + url; + } + String scheme = uri.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { + return "only http(s) endpoints are allowed, got: " + scheme; + } + String host = uri.getHost(); + if (host == null || host.isBlank()) { + return "URL has no host: " + url; + } + boolean allowPrivate = use == EndpointUse.MCP_SERVER + ? properties.getMcp().isAllowPrivateEndpoints() + : properties.getOpenAiCompatible().isAllowPrivateEndpoints(); + + InetAddress[] addresses; + try { + addresses = resolver.resolve(host); + } catch (UnknownHostException exception) { + return "cannot resolve host '" + host + "'"; + } + for (InetAddress address : addresses) { + String reason = classify(address, allowPrivate); + if (reason != null) { + return reason; + } + } + return null; + } + + @Override + public boolean stdioTransportAllowed() { + return properties.getMcp().isStdioEnabled(); + } + + @Override + public boolean envRefAllowed(String name) { + return properties.getMcp().getEnvAllowlist().contains(name); + } + + /** @return null when the address is allowed, otherwise the rejection reason. */ + private static String classify(InetAddress address, boolean allowPrivate) { + if (address.isAnyLocalAddress()) { + return "unspecified address is not a valid endpoint: " + address.getHostAddress(); + } + if (address.isLinkLocalAddress()) { + // 169.254.0.0/16 (incl. the cloud metadata service) and fe80::/10 + return "link-local address (cloud metadata range) is always blocked: " + + address.getHostAddress(); + } + if (address.isMulticastAddress()) { + return "multicast address is not a valid endpoint: " + address.getHostAddress(); + } + boolean privateRange = address.isLoopbackAddress() + || address.isSiteLocalAddress() + || isUniqueLocalIpv6(address) + || isCarrierGradeNat(address) + || isBenchmarkRange(address); + if (privateRange && !allowPrivate) { + return "private/loopback address is blocked unless allow-private-endpoints" + + " is enabled: " + address.getHostAddress(); + } + return null; + } + + /** fc00::/7 unique-local addresses are not covered by {@code isSiteLocalAddress}. */ + private static boolean isUniqueLocalIpv6(InetAddress address) { + byte[] bytes = address.getAddress(); + return bytes.length == 16 && (bytes[0] & 0xFE) == 0xFC; + } + + /** 100.64.0.0/10 carrier-grade NAT, a common internal service range. */ + private static boolean isCarrierGradeNat(InetAddress address) { + byte[] bytes = address.getAddress(); + return bytes.length == 4 && (bytes[0] & 0xFF) == 100 + && (bytes[1] & 0xFF) >= 64 && (bytes[1] & 0xFF) <= 127; + } + + /** + * 198.18.0.0/15 RFC 2544 benchmark range: reserved, never a legitimate + * endpoint, and used as the synthetic pool by fake-IP VPN/proxy resolvers. + */ + private static boolean isBenchmarkRange(InetAddress address) { + byte[] bytes = address.getAddress(); + return bytes.length == 4 && (bytes[0] & 0xFF) == 198 + && (bytes[1] & 0xFE) == 18; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java new file mode 100644 index 000000000..737e8765c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java @@ -0,0 +1,325 @@ +package com.iflytek.skillhub.controller.authoring; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.service.DraftSubmitService; +import com.iflytek.skillhub.domain.authoring.service.FindingFixService; +import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingService; +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.CreateDraftRequest; +import com.iflytek.skillhub.dto.DraftFileContentResponse; +import com.iflytek.skillhub.dto.DraftFileResponse; +import com.iflytek.skillhub.dto.DraftResponse; +import com.iflytek.skillhub.dto.RuntimeBindingRequest; +import com.iflytek.skillhub.dto.RuntimeBindingResponse; +import com.iflytek.skillhub.dto.SaveDraftFileRequest; +import com.iflytek.skillhub.dto.SaveDraftFileResponse; +import com.iflytek.skillhub.dto.SubmitDraftRequest; +import com.iflytek.skillhub.dto.SubmitDraftResponse; +import com.iflytek.skillhub.dto.ValidationEventResponse; +import com.iflytek.skillhub.dto.ValidationFindingResponse; +import com.iflytek.skillhub.dto.ValidationRunResponse; +import com.iflytek.skillhub.service.authoring.ValidationRunOrchestrator; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Skill authoring workbench API: draft CRUD and file editing, runtime binding, + * validation run lifecycle (start, cancel, events, findings), fix application, + * and submission of validated drafts into the publish pipeline. + */ +@Tag(name = "Skill authoring") +@RestController +@RequestMapping({"/api/v1/authoring", "/api/web/authoring"}) +public class SkillAuthoringController extends BaseApiController { + + private final SkillDraftService draftService; + private final RuntimeBindingService bindingService; + private final ValidationRunService runService; + private final DraftSubmitService submitService; + private final FindingFixService fixService; + private final ValidationRunOrchestrator orchestrator; + + public SkillAuthoringController(com.iflytek.skillhub.dto.ApiResponseFactory responseFactory, + SkillDraftService draftService, + RuntimeBindingService bindingService, + ValidationRunService runService, + DraftSubmitService submitService, + FindingFixService fixService, + ValidationRunOrchestrator orchestrator) { + super(responseFactory); + this.draftService = draftService; + this.bindingService = bindingService; + this.runService = runService; + this.submitService = submitService; + this.fixService = fixService; + this.orchestrator = orchestrator; + } + + // ---------------------------------------------------------------- drafts + + @Operation(operationId = "createAuthoringDraft", summary = "Create a draft seeded with a SKILL.md scaffold") + @PostMapping("/drafts") + public ApiResponse createDraft(@Valid @RequestBody CreateDraftRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + SkillDraft draft = draftService.createDraft( + request.namespaceSlug(), userId, request.name(), request.requirement(), platformRoles); + return ok("response.success.created", DraftResponse.from(draft)); + } + + @Operation(operationId = "listAuthoringDrafts", summary = "List the current user's drafts") + @GetMapping("/drafts") + public ApiResponse> listDrafts(@RequestAttribute("userId") String userId) { + return ok("response.success", draftService.listDrafts(userId).stream() + .map(DraftResponse::from) + .toList()); + } + + @Operation(operationId = "getAuthoringDraft", summary = "Get one draft owned by the current user") + @GetMapping("/drafts/{draftId}") + public ApiResponse getDraft(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + return ok("response.success", + DraftResponse.from(draftService.getOwnedDraft(draftId, userId, platformRoles))); + } + + @Operation(operationId = "deleteAuthoringDraft", summary = "Delete a draft and its files") + @DeleteMapping("/drafts/{draftId}") + public ApiResponse deleteDraft(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + draftService.deleteDraft(draftId, userId, platformRoles); + return ok("response.success.deleted", null); + } + + // ---------------------------------------------------------------- draft files + + @Operation(operationId = "listDraftFiles", summary = "List a draft's files with metadata") + @GetMapping("/drafts/{draftId}/files") + public ApiResponse> listFiles(@PathVariable Long draftId) { + return ok("response.success", draftService.listFiles(draftId).stream() + .map(DraftFileResponse::from) + .toList()); + } + + @Operation(operationId = "saveDraftFile", summary = "Create or update one draft file") + @PutMapping("/drafts/{draftId}/files") + public ApiResponse saveFile(@PathVariable Long draftId, + @Valid @RequestBody SaveDraftFileRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + byte[] content = request.isBase64() + ? Base64.getDecoder().decode(request.content()) + : request.content().getBytes(StandardCharsets.UTF_8); + return ok("response.success.updated", SaveDraftFileResponse.from( + draftService.saveFile(draftId, userId, request.path(), content, + request.contentType(), request.expectedRevision(), platformRoles))); + } + + @Operation(operationId = "readDraftFile", summary = "Read one draft file's content") + @GetMapping("/drafts/{draftId}/files/content") + public ApiResponse readFile(@PathVariable Long draftId, + @RequestParam("path") String path, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + SkillDraftService.FileContent content = + draftService.readFile(draftId, userId, path, platformRoles); + return ok("response.success", new DraftFileContentResponse( + content.file().getFilePath(), + content.file().getSha256(), + content.file().getSize(), + content.file().getContentType(), + content.asText())); + } + + @Operation(operationId = "deleteDraftFile", summary = "Delete one draft file") + @DeleteMapping("/drafts/{draftId}/files") + public ApiResponse deleteFile(@PathVariable Long draftId, + @RequestParam("path") String path, + @RequestParam(value = "expectedRevision", required = false) + Integer expectedRevision, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + draftService.deleteFile(draftId, userId, path, expectedRevision, platformRoles); + return ok("response.success.deleted", null); + } + + // ---------------------------------------------------------------- runtime binding + + @Operation(operationId = "getDraftRuntimeBinding", summary = "Get the draft's runtime binding") + @GetMapping("/drafts/{draftId}/runtime") + public ApiResponse getRuntime(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + draftService.getOwnedDraft(draftId, userId, platformRoles); + return ok("response.success", bindingService.findBinding(draftId) + .map(this::toRuntimeResponse) + .orElseGet(() -> new RuntimeBindingResponse(null, Map.of(), List.of(), List.of(), null))); + } + + @Operation(operationId = "saveDraftRuntimeBinding", summary = "Configure the agent runtime, tools, and MCP servers") + @PutMapping("/drafts/{draftId}/runtime") + public ApiResponse saveRuntime(@PathVariable Long draftId, + @Valid @RequestBody RuntimeBindingRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + return ok("response.success.updated", toRuntimeResponse(bindingService.saveBinding( + draftId, userId, request.agentType(), request.config(), + request.toolAllowlist(), request.mcpServers(), platformRoles))); + } + + private RuntimeBindingResponse toRuntimeResponse( + com.iflytek.skillhub.domain.authoring.RuntimeBinding binding) { + return new RuntimeBindingResponse( + binding.getAgentType().identifier(), + binding.getConfig() == null ? Map.of() : binding.getConfig(), + binding.getToolAllowlist() == null ? List.of() : binding.getToolAllowlist(), + binding.getMcpServers() == null ? List.of() : binding.getMcpServers(), + binding.getUpdatedAt()); + } + + // ---------------------------------------------------------------- validation runs + + @Operation(operationId = "startValidationRun", summary = "Start a validation run for the draft's current revision") + @PostMapping("/drafts/{draftId}/runs") + public ApiResponse startRun(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = runService.startRun(draftId, userId, platformRoles); + orchestrator.submit(run.getId()); + return ok("response.success.created", ValidationRunResponse.from(run)); + } + + @Operation(operationId = "listValidationRuns", summary = "List the draft's validation runs") + @GetMapping("/drafts/{draftId}/runs") + public ApiResponse> listRuns(@PathVariable Long draftId) { + return ok("response.success", runService.listRuns(draftId).stream() + .map(ValidationRunResponse::from) + .toList()); + } + + @Operation(operationId = "getValidationRun", summary = "Get one validation run") + @GetMapping("/runs/{runId}") + public ApiResponse getRun(@PathVariable Long runId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = runService.getOwnedRun(runId, userId, platformRoles); + return ok("response.success", ValidationRunResponse.from(run)); + } + + @Operation(operationId = "cancelValidationRun", summary = "Request cooperative cancellation of a run") + @PostMapping("/runs/{runId}/cancel") + public ApiResponse cancelRun(@PathVariable Long runId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = orchestrator.requestCancel(runId, userId, platformRoles); + return ok("response.success.updated", ValidationRunResponse.from(run)); + } + + @Operation(operationId = "listValidationEvents", summary = "List run events after a sequence cursor (polling)") + @GetMapping("/runs/{runId}/events") + public ApiResponse> listEvents( + @PathVariable Long runId, + @RequestParam(value = "afterSeq", required = false) Integer afterSeq, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + runService.getOwnedRun(runId, userId, platformRoles); + return ok("response.success", runService.listEvents(runId, afterSeq).stream() + .map(ValidationEventResponse::from) + .toList()); + } + + // ---------------------------------------------------------------- findings & fixes + + @Operation(operationId = "listValidationFindings", summary = "List a run's findings with fix suggestions") + @GetMapping("/runs/{runId}/findings") + public ApiResponse> listFindings( + @PathVariable Long runId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + runService.getOwnedRun(runId, userId, platformRoles); + return ok("response.success", runService.listFindings(runId).stream() + .map(ValidationFindingResponse::from) + .toList()); + } + + @Operation(operationId = "applyFindingFix", summary = "Apply a finding's fix suggestion to the draft") + @PostMapping("/runs/{runId}/findings/{findingId}/apply") + public ApiResponse applyFindingFix( + @PathVariable Long runId, + @PathVariable Long findingId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + FindingFixService.FixOutcome outcome = fixService.applyFix(findingId, userId, platformRoles); + return ok("response.success.updated", ValidationFindingResponse.from(outcome.finding())); + } + + @Operation(operationId = "dismissFindingFix", summary = "Dismiss a finding as not applicable") + @PostMapping("/runs/{runId}/findings/{findingId}/dismiss") + public ApiResponse dismissFinding( + @PathVariable Long runId, + @PathVariable Long findingId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationFinding finding = fixService.dismissFinding(findingId, userId, platformRoles); + return ok("response.success.updated", ValidationFindingResponse.from(finding)); + } + + // ---------------------------------------------------------------- submission + + @Operation(operationId = "submitValidatedDraft", summary = "Submit a validated draft into the publish pipeline") + @PostMapping("/drafts/{draftId}/submit") + public ApiResponse submitDraft(@PathVariable Long draftId, + @RequestBody(required = false) SubmitDraftRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + SkillVisibility visibility = request == null || request.visibility() == null + ? SkillVisibility.PRIVATE + : SkillVisibility.valueOf(request.visibility()); + Set roles = request == null || request.platformRoles() == null + ? platformRoles + : request.platformRoles(); + return ok("response.success.created", + SubmitDraftResponse.from(submitService.submit(draftId, userId, visibility, roles))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/ValidationStreamController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/ValidationStreamController.java new file mode 100644 index 000000000..92e6ead2e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/ValidationStreamController.java @@ -0,0 +1,68 @@ +package com.iflytek.skillhub.controller.authoring; + +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.service.authoring.ValidationEventBroadcaster; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.Set; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * Server-sent events for validation runs. Browsers authenticate via the session + * cookie (EventSource cannot send headers); token clients use the /api/v1 variant. + * Event ids are the per-run sequence numbers, so reconnecting clients resume from + * Last-Event-ID without gaps or duplicates. + */ +@Tag(name = "Skill authoring") +@RestController +@RequestMapping({"/api/v1/authoring", "/api/web/authoring"}) +public class ValidationStreamController { + + private final ValidationRunService runService; + private final ValidationEventBroadcaster broadcaster; + + public ValidationStreamController(ValidationRunService runService, + ValidationEventBroadcaster broadcaster) { + this.runService = runService; + this.broadcaster = broadcaster; + } + + @Operation(operationId = "streamValidationEvents", + summary = "Stream run events over SSE; resumes from Last-Event-ID") + @GetMapping(value = "/runs/{runId}/events/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamEvents(@PathVariable Long runId, + @RequestParam(value = "afterSeq", required = false) Integer afterSeq, + @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationRun run = runService.getOwnedRun(runId, userId, platformRoles); + int lastSeq = resolveCursor(afterSeq, lastEventId); + List replay = runService.listEvents(run.getId(), lastSeq); + return broadcaster.subscribe(run.getId(), lastSeq, replay); + } + + private int resolveCursor(Integer afterSeq, String lastEventId) { + int fromParam = afterSeq == null ? 0 : Math.max(0, afterSeq); + int fromHeader = 0; + if (lastEventId != null && !lastEventId.isBlank()) { + try { + fromHeader = Math.max(0, Integer.parseInt(lastEventId.trim())); + } catch (NumberFormatException ignored) { + // malformed Last-Event-ID restarts from the beginning + } + } + return Math.max(fromParam, fromHeader); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateDraftRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateDraftRequest.java new file mode 100644 index 000000000..cc59ea4d9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/CreateDraftRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +/** Request body for creating a skill authoring draft. */ +public record CreateDraftRequest( + @NotBlank(message = "{error.badRequest}") String namespaceSlug, + @NotBlank(message = "{error.badRequest}") String name, + String requirement +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileContentResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileContentResponse.java new file mode 100644 index 000000000..a1d283c03 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileContentResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +/** Draft file content, always delivered as text (draft files are text formats). */ +public record DraftFileContentResponse( + String path, + String sha256, + Long size, + String contentType, + String content +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileResponse.java new file mode 100644 index 000000000..f2f25c2bb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftFileResponse.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.DraftFile; +import java.time.Instant; + +/** Draft file metadata (content is fetched separately). */ +public record DraftFileResponse( + Long id, + String path, + String sha256, + Long size, + String contentType, + Instant updatedAt +) { + + public static DraftFileResponse from(DraftFile file) { + return new DraftFileResponse( + file.getId(), + file.getFilePath(), + file.getSha256(), + file.getSize(), + file.getContentType(), + file.getUpdatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftResponse.java new file mode 100644 index 000000000..60aa7bb94 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DraftResponse.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import java.time.Instant; + +/** Draft summary/aggregate exposed to the authoring UI. */ +public record DraftResponse( + Long id, + Long namespaceId, + String name, + String requirement, + Integer revision, + String contentDigest, + boolean validated, + Integer validatedRevision, + Long validatedRunId, + Long submittedSkillId, + Long submittedVersionId, + Instant submittedAt, + Instant createdAt, + Instant updatedAt +) { + + public static DraftResponse from(SkillDraft draft) { + return new DraftResponse( + draft.getId(), + draft.getNamespaceId(), + draft.getName(), + draft.getRequirement(), + draft.getRevision(), + draft.getContentDigest(), + draft.isCurrentRevisionValidated(), + draft.getValidatedRevision(), + draft.getValidatedRunId(), + draft.getSubmittedSkillId(), + draft.getSubmittedVersionId(), + draft.getSubmittedAt(), + draft.getCreatedAt(), + draft.getUpdatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingRequest.java new file mode 100644 index 000000000..2a2cef8f0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingRequest.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import java.util.Map; + +/** + * Request body for the per-draft runtime binding: which agent runtime executes + * behavior tasks, its adapter configuration, the tool allowlist, and MCP server + * declarations. Credentials must be referenced by name, never inlined. + */ +public record RuntimeBindingRequest( + @NotBlank(message = "{error.badRequest}") String agentType, + Map config, + List toolAllowlist, + List> mcpServers +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingResponse.java new file mode 100644 index 000000000..7489cbb64 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingResponse.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** Runtime binding as seen by the authoring UI. */ +public record RuntimeBindingResponse( + String agentType, + Map config, + List toolAllowlist, + List> mcpServers, + Instant updatedAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileRequest.java new file mode 100644 index 000000000..88c82ab06 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileRequest.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * Request body for saving one draft file. Text is the default encoding; base64 + * transports binary-safe content. + */ +public record SaveDraftFileRequest( + @NotBlank(message = "{error.badRequest}") String path, + @NotNull(message = "{error.badRequest}") String content, + String encoding, + String contentType, + Integer expectedRevision +) { + + public boolean isBase64() { + return "base64".equalsIgnoreCase(encoding); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileResponse.java new file mode 100644 index 000000000..e0b162020 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SaveDraftFileResponse.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; + +/** Result of saving one draft file: updated draft plus file metadata. */ +public record SaveDraftFileResponse( + DraftResponse draft, + DraftFileResponse file, + boolean created, + boolean revisionAdvanced +) { + + public static SaveDraftFileResponse from(SkillDraftService.SaveFileOutcome outcome) { + return new SaveDraftFileResponse( + DraftResponse.from(outcome.draft()), + DraftFileResponse.from(outcome.file()), + outcome.created(), + outcome.revisionAdvanced()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftRequest.java new file mode 100644 index 000000000..b6fe7eaff --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import java.util.Set; + +/** Request body for submitting a validated draft into the publish pipeline. */ +public record SubmitDraftRequest( + String visibility, + Set platformRoles +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftResponse.java new file mode 100644 index 000000000..ebaaf9823 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftResponse.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.service.DraftSubmitService; + +/** Outcome of submitting a validated draft: the published skill version coordinates. */ +public record SubmitDraftResponse( + Long skillId, + Long versionId, + String slug, + String version +) { + + public static SubmitDraftResponse from(DraftSubmitService.SubmitOutcome outcome) { + return new SubmitDraftResponse( + outcome.skillId(), outcome.versionId(), outcome.slug(), outcome.version()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationEventResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationEventResponse.java new file mode 100644 index 000000000..9a7265b99 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationEventResponse.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import java.time.Instant; +import java.util.Map; + +/** One persisted validation event (log line, tool call, finding, phase marker...). */ +public record ValidationEventResponse( + Long id, + Long runId, + Integer seq, + String type, + String phase, + Map payload, + Instant createdAt +) { + + public static ValidationEventResponse from(ValidationEvent event) { + return new ValidationEventResponse( + event.getId(), + event.getRunId(), + event.getSeq(), + event.getEventType().name(), + event.getPhase(), + event.getPayload() == null ? Map.of() : event.getPayload(), + event.getCreatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationFindingResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationFindingResponse.java new file mode 100644 index 000000000..a0d85379b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationFindingResponse.java @@ -0,0 +1,41 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.FixSuggestion; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import java.time.Instant; + +/** + * One validation finding. {@code suggestion} carries the machine-applicable fix + * (patches with old/new values) when one exists, for preview and confirmed apply. + */ +public record ValidationFindingResponse( + Long id, + Long runId, + String layer, + String ruleCode, + String severity, + String filePath, + String location, + String message, + FixSuggestion suggestion, + String status, + Integer appliedRevision, + Instant createdAt +) { + + public static ValidationFindingResponse from(ValidationFinding finding) { + return new ValidationFindingResponse( + finding.getId(), + finding.getRunId(), + finding.getLayer().name(), + finding.getRuleCode(), + finding.getSeverity().name(), + finding.getFilePath(), + finding.getLocation(), + finding.getMessage(), + finding.getSuggestion(), + finding.getStatus().name(), + finding.getAppliedRevision(), + finding.getCreatedAt()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationRunResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationRunResponse.java new file mode 100644 index 000000000..921a05d19 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ValidationRunResponse.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import java.time.Instant; +import java.util.Map; + +/** Validation run state and outcome counters. */ +public record ValidationRunResponse( + Long id, + Long draftId, + Integer draftRevision, + String status, + boolean cancelRequested, + boolean active, + boolean terminal, + Integer errorCount, + Integer warningCount, + String triggeredBy, + Instant startedAt, + Instant finishedAt, + Instant createdAt, + Map summary +) { + + public static ValidationRunResponse from(ValidationRun run) { + return from(run, run.getSummary()); + } + + public static ValidationRunResponse from(ValidationRun run, Map summary) { + return new ValidationRunResponse( + run.getId(), + run.getDraftId(), + run.getDraftRevision(), + run.getStatus().name(), + run.isCancelRequested(), + run.isActive(), + run.getStatus().isTerminal(), + run.getErrorCount(), + run.getWarningCount(), + run.getTriggeredBy(), + run.getStartedAt(), + run.getFinishedAt(), + run.getCreatedAt(), + summary == null ? Map.of() : summary); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationEventBroadcaster.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationEventBroadcaster.java new file mode 100644 index 000000000..0dc5eaeba --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationEventBroadcaster.java @@ -0,0 +1,153 @@ +package com.iflytek.skillhub.service.authoring; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventType; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +/** + * In-memory SSE hub for validation runs. Each subscriber tracks the last sequence + * number it saw, so replay (Last-Event-ID / afterSeq) and live delivery can be + * interleaved without duplicates. The hub is per-instance by design: the current + * deployment runs one backend node, and remote clients always have the polling + * endpoint as a fallback. + */ +@Component +public class ValidationEventBroadcaster { + + /** SSE connection lifetime cap; clients reconnect with Last-Event-ID on expiry. */ + static final long EMITTER_TIMEOUT_MS = 30L * 60 * 1000; + + private final Map> sessionsByRun = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper; + + public ValidationEventBroadcaster(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Registers a live emitter and replays already-persisted events through it. The + * session cursor makes replay and concurrent live delivery duplicate-free and + * order-preserving: every send fires only for sequence numbers the session has + * not delivered yet. + * + * @param runId the run to subscribe to + * @param lastSeq the subscriber's last seen sequence number (Last-Event-ID) + * @param replay persisted events after {@code lastSeq}, oldest first + */ + public SseEmitter subscribe(Long runId, int lastSeq, List replay) { + EmitterSession session = new EmitterSession(lastSeq); + sessionsByRun.computeIfAbsent(runId, key -> new CopyOnWriteArrayList<>()).add(session); + Runnable detach = () -> detach(runId, session); + session.emitter().onCompletion(detach); + session.emitter().onTimeout(detach); + session.emitter().onError(error -> detach.run()); + + boolean terminal = false; + for (ValidationEvent event : replay) { + if (!session.send(event.getSeq(), envelope(event))) { + detach(runId, session); + return session.emitter(); + } + if (event.getEventType() == ValidationEventType.RUN_FINISHED) { + terminal = true; + } + } + if (terminal) { + // run already finished: close the stream so clients stop reconnecting + session.emitter().complete(); + } + return session.emitter(); + } + + /** Pushes one persisted event to every live subscriber of the run. */ + public void publish(Long runId, ValidationEvent event) { + List sessions = sessionsByRun.get(runId); + if (sessions == null) { + return; + } + String envelope = envelope(event); + for (EmitterSession session : sessions) { + if (!session.send(event.getSeq(), envelope)) { + detach(runId, session); + } + } + } + + /** Sends the terminal event and completes all subscribers of a finished run. */ + public void completeRun(Long runId, ValidationEvent terminalEvent) { + publish(runId, terminalEvent); + List sessions = sessionsByRun.remove(runId); + if (sessions == null) { + return; + } + for (EmitterSession session : sessions) { + session.emitter().complete(); + } + } + + private void detach(Long runId, EmitterSession session) { + List sessions = sessionsByRun.get(runId); + if (sessions != null) { + sessions.remove(session); + } + } + + private String envelope(ValidationEvent event) { + Map payload = event.getPayload() == null ? Map.of() : event.getPayload(); + Map envelope = Map.of( + "seq", event.getSeq(), + "type", event.getEventType().name(), + "phase", event.getPhase() == null ? "" : event.getPhase(), + "payload", payload, + "createdAt", event.getCreatedAt().toString()); + try { + return objectMapper.writeValueAsString(envelope); + } catch (IOException exception) { + return "{\"seq\":" + event.getSeq() + ",\"type\":\"" + event.getEventType() + "\"}"; + } + } + + /** One subscriber connection with its own delivery cursor and serialized writes. */ + static final class EmitterSession { + + private final SseEmitter emitter; + private int lastSeq; + + EmitterSession(int lastSeq) { + this.emitter = new SseEmitter(EMITTER_TIMEOUT_MS); + this.lastSeq = lastSeq; + } + + SseEmitter emitter() { + return emitter; + } + + int lastSeq() { + return lastSeq; + } + + /** Sends unless the event predates this session's cursor; false means the pipe broke. */ + synchronized boolean send(int seq, String envelope) { + if (seq <= lastSeq) { + return true; + } + try { + emitter.send(SseEmitter.event() + .id(String.valueOf(seq)) + .data(envelope, MediaType.APPLICATION_JSON)); + lastSeq = seq; + return true; + } catch (IOException | IllegalStateException exception) { + return false; + } + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java new file mode 100644 index 000000000..23ccdce00 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java @@ -0,0 +1,584 @@ +package com.iflytek.skillhub.service.authoring; + +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.RuntimeBinding; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.runtime.AssertionEvaluator; +import com.iflytek.skillhub.domain.authoring.runtime.AssertionFailure; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext; +import com.iflytek.skillhub.domain.authoring.runtime.SkillRuntimeAdapter; +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import com.iflytek.skillhub.domain.authoring.service.DraftStructureValidator; +import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingService; +import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingValidator; +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationSpec; +import com.iflytek.skillhub.domain.authoring.spec.ValidationSpecParser; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.FindingSeverity; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventType; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.service.authoring.mcp.McpProbeService; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +/** + * Executes validation runs on a dedicated executor: materializes the draft into an + * isolated workspace, runs the three validation layers (structure, configuration, + * behavior), persists ordered events and findings as they occur, and settles the run. + * + *

All terminal transitions funnel through {@link #settle}, which appends exactly one + * RUN_FINISHED event per run regardless of whether the run ends normally, is cancelled, + * times out, or is swept after a crash. + */ +@Component +public class ValidationRunOrchestrator { + + private static final Logger log = LoggerFactory.getLogger(ValidationRunOrchestrator.class); + private static final String SPEC_FILE = ValidationSpec.FILE_PATH; + private static final int MAX_EVENT_TEXT = 4_000; + + private final ValidationRunService runService; + private final SkillDraftService draftService; + private final RuntimeBindingService bindingService; + private final DraftStructureValidator structureValidator; + private final RuntimeBindingValidator bindingValidator; + private final McpProbeService mcpProbeService; + private final ValidationSpecParser specParser = new ValidationSpecParser(); + private final AssertionEvaluator assertionEvaluator = new AssertionEvaluator(); + private final List adapters; + private final ValidationEventBroadcaster broadcaster; + private final AuthoringProperties properties; + private final Executor executor; + + /** Cooperative cancel flags mirroring the persisted cancelRequested flag for fast reaction. */ + private final Map cancelFlags = new ConcurrentHashMap<>(); + + /** Per-run monitors making the RUN_FINISHED append-and-broadcast exactly-once. */ + private final Map terminalLocks = new ConcurrentHashMap<>(); + + public ValidationRunOrchestrator(ValidationRunService runService, + SkillDraftService draftService, + RuntimeBindingService bindingService, + DraftStructureValidator structureValidator, + RuntimeBindingValidator bindingValidator, + McpProbeService mcpProbeService, + List adapters, + ValidationEventBroadcaster broadcaster, + AuthoringProperties properties, + @Qualifier("authoringValidationExecutor") Executor executor) { + this.runService = runService; + this.draftService = draftService; + this.bindingService = bindingService; + this.structureValidator = structureValidator; + this.bindingValidator = bindingValidator; + this.mcpProbeService = mcpProbeService; + this.adapters = List.copyOf(adapters); + this.broadcaster = broadcaster; + this.properties = properties; + this.executor = executor; + } + + /** Enqueues one QUEUED run for execution. */ + public void submit(Long runId) { + executor.execute(() -> executeRun(runId)); + } + + /** + * Requests cooperative cancellation. QUEUED runs settle immediately; running runs + * settle when their executor observes the flag (between tasks or inside adapters). + */ + public ValidationRun requestCancel(Long runId, String userId, Set platformRoles) { + ValidationRun run = runService.requestCancel(runId, userId, platformRoles); + cancelFlags.computeIfAbsent(runId, key -> new AtomicBoolean()).set(true); + if (run.getStatus().isTerminal()) { + emitTerminalOnce(runId); + } + return run; + } + + /** + * Idempotent terminal settle: persists the outcome (first writer wins), appends the + * RUN_FINISHED event exactly once, and completes SSE subscribers. + */ + public void settle(Long runId, ValidationRunStatus status, int errorCount, int warningCount, + Map summary) { + runService.settleIfActive(runId, status, errorCount, warningCount, summary); + emitTerminalOnce(runId); + } + + // ---------------------------------------------------------------- execution + + private void executeRun(Long runId) { + try { + ValidationRun run = runService.claimForExecution(runId); + if (run.getStatus().isTerminal()) { + emitTerminalOnce(runId); + return; + } + executePipeline(run); + } catch (DomainConflictException exception) { + // cancelled while queued, or already claimed/settled elsewhere + log.info("Validation run {} no longer claimable: {}", runId, exception.getMessage()); + emitTerminalOnce(runId); + } catch (Exception exception) { + log.error("Validation run {} crashed", runId, exception); + settle(runId, ValidationRunStatus.FAILED, 1, 0, + Map.of("internalError", truncate(String.valueOf(exception.getMessage()), 500))); + } finally { + cancelFlags.remove(runId); + terminalLocks.remove(runId); + } + } + + private void executePipeline(ValidationRun run) { + Long runId = run.getId(); + SkillDraft draft = draftService.getDraft(run.getDraftId()); + List entries = draftService.materializeEntries(draft.getId()); + RunContext context = new RunContext(runId); + + emit(runId, ValidationEventType.RUN_STARTED, null, Map.of( + "draftId", draft.getId(), + "draftName", draft.getName(), + "draftRevision", run.getDraftRevision())); + + Path workspace = workspaceFor(runId); + try { + materializeWorkspace(workspace, entries); + + // ---- STRUCTURE layer + emit(runId, ValidationEventType.PHASE_STARTED, "STRUCTURE", Map.of()); + DraftStructureValidator.StructureReport structure = + structureValidator.validate(draft.getName(), draft.getRequirement(), entries); + structure.findings().forEach(context::recordFinding); + emit(runId, ValidationEventType.PHASE_FINISHED, "STRUCTURE", Map.of( + "findings", structure.findings().size(), + "errors", context.errorCount(ValidationLayer.STRUCTURE), + "warnings", context.warningCount(ValidationLayer.STRUCTURE))); + + // ---- CONFIG layer + emit(runId, ValidationEventType.PHASE_STARTED, "CONFIG", Map.of()); + ValidationSpec spec = runConfigLayer(context, draft, entries); + emit(runId, ValidationEventType.PHASE_FINISHED, "CONFIG", Map.of( + "errors", context.errorCount(ValidationLayer.CONFIG), + "warnings", context.warningCount(ValidationLayer.CONFIG))); + + // ---- BEHAVIOR layer + emit(runId, ValidationEventType.PHASE_STARTED, "BEHAVIOR", Map.of()); + boolean blocked = context.errorCount(ValidationLayer.STRUCTURE) > 0 + || context.errorCount(ValidationLayer.CONFIG) > 0; + if (blocked) { + emit(runId, ValidationEventType.PHASE_FINISHED, "BEHAVIOR", + Map.of("skipped", true, "reason", "structure or configuration errors")); + } else { + BehaviorOutcome outcome = runBehaviorLayer(context, run, draft, spec, workspace); + if (outcome.interrupted()) { + return; // already settled (CANCELLED / TIMED_OUT) with terminal event + } + emit(runId, ValidationEventType.PHASE_FINISHED, "BEHAVIOR", Map.of( + "skipped", false, + "tasksRun", context.tasksRun, + "tasksFailed", context.tasksFailed)); + } + + settle(runId, + context.errorCount == 0 ? ValidationRunStatus.SUCCEEDED : ValidationRunStatus.FAILED, + context.errorCount, context.warningCount, summary(context, blocked)); + } catch (Exception exception) { + log.error("Validation pipeline for run {} failed", runId, exception); + settle(runId, ValidationRunStatus.FAILED, context.errorCount + 1, context.warningCount, + Map.of("internalError", truncate(String.valueOf(exception.getMessage()), 500))); + } finally { + deleteRecursively(workspace); + } + } + + // ---------------------------------------------------------------- config layer + + private ValidationSpec runConfigLayer(RunContext context, SkillDraft draft, + List entries) { + String specContent = readEntryText(entries, SPEC_FILE); + ValidationSpec spec = ValidationSpec.empty(); + if (specContent == null) { + context.recordFinding(FindingDraft.warning(ValidationLayer.CONFIG, "SPEC_MISSING", + SPEC_FILE, "validation.yaml is absent; behavior layer runs zero tasks")); + } else { + ValidationSpecParser.ParseOutcome outcome = specParser.parse(specContent); + outcome.errors().forEach(error -> context.recordFinding(FindingDraft.error( + ValidationLayer.CONFIG, error.ruleCode(), SPEC_FILE, error.message(), null))); + if (outcome.isValid()) { + spec = outcome.spec(); + } + } + + // The binding is checked (and its MCP servers probed) even when + // validation.yaml is absent: a binding that cannot work must fail the + // run here, not surface for the first time at submit. + RuntimeBinding binding = bindingService.findBinding(draft.getId()).orElse(null); + if (binding == null) { + if (!spec.safeTasks().isEmpty()) { + context.recordFinding(FindingDraft.error(ValidationLayer.CONFIG, "BINDING_MISSING", + "No runtime binding configured, but validation.yaml declares " + + spec.safeTasks().size() + " task(s)")); + } + return spec; + } + + bindingValidator.validate( + binding.getAgentType().identifier(), + binding.getConfig(), + binding.getToolAllowlist(), + binding.getMcpServers()) + .forEach(context::recordFinding); + + // declared MCP servers must actually answer before the behavior layer + // relies on their tools: connect, initialize, tools/list + List> mcpServers = binding.getMcpServers() == null + ? List.of() : binding.getMcpServers(); + if (!mcpServers.isEmpty()) { + McpProbeService.ProbeReport probeReport = + mcpProbeService.probe(mcpServers, java.time.Duration.ofSeconds(10)); + probeReport.findings().forEach(context::recordFinding); + for (McpProbeService.ServerProbe serverProbe : probeReport.servers()) { + if (serverProbe.connected()) { + emit(context.runId, ValidationEventType.LOG, "CONFIG", Map.of( + "task", "mcp:" + serverProbe.server(), "stream", "summary", + "line", "connected; tools: " + (serverProbe.tools().isEmpty() + ? "(none)" : String.join(", ", serverProbe.tools())))); + } + } + } + + SkillRuntimeAdapter adapter = findAdapter(binding.getAgentType().identifier()); + if (adapter == null) { + context.recordFinding(FindingDraft.error(ValidationLayer.CONFIG, "ADAPTER_NOT_FOUND", + "No runtime adapter is registered for agent type '" + + binding.getAgentType().identifier() + "'")); + } else if (!adapter.enabled()) { + context.recordFinding(FindingDraft.error(ValidationLayer.CONFIG, "RUNTIME_DISABLED", + "Runtime '" + binding.getAgentType().identifier() + + "' is disabled in server configuration")); + } + + for (ValidationTaskSpec task : spec.safeTasks()) { + assertionEvaluator.validateSyntax(task.safeAssertions(), task.type() == TaskType.PROMPT) + .forEach(problem -> context.recordFinding(FindingDraft.error( + ValidationLayer.CONFIG, "ASSERTION_INVALID", SPEC_FILE, + "task '" + task.name() + "': " + problem, null))); + } + return spec; + } + + // ---------------------------------------------------------------- behavior layer + + private BehaviorOutcome runBehaviorLayer(RunContext context, ValidationRun run, + SkillDraft draft, ValidationSpec spec, Path workspace) { + RuntimeBinding binding = bindingService.findBinding(draft.getId()).orElse(null); + if (binding == null || spec.safeTasks().isEmpty()) { + return BehaviorOutcome.COMPLETED; + } + SkillRuntimeAdapter adapter = findAdapter(binding.getAgentType().identifier()); + if (adapter == null || !adapter.enabled()) { + return BehaviorOutcome.COMPLETED; // already reported by the config layer + } + + long deadline = System.currentTimeMillis() + properties.getRunTimeoutMs(); + Map adapterConfig = binding.getConfig() == null + ? Map.of() : binding.getConfig(); + List toolAllowlist = binding.getToolAllowlist() == null + ? List.of() : binding.getToolAllowlist(); + AtomicBoolean cancelFlag = cancelFlags.computeIfAbsent(run.getId(), + key -> new AtomicBoolean(false)); + RuntimeExecutionContext executionContext = new RuntimeExecutionContext( + run.getId(), workspace, adapterConfig, toolAllowlist, + binding.getMcpServers() == null ? List.of() : binding.getMcpServers(), + cancelFlag::get); + + for (ValidationTaskSpec task : spec.safeTasks()) { + if (cancelFlag.get()) { + settle(run.getId(), ValidationRunStatus.CANCELLED, + context.errorCount, context.warningCount, summary(context, false)); + return BehaviorOutcome.interrupted("cancelled"); + } + if (System.currentTimeMillis() > deadline) { + settle(run.getId(), ValidationRunStatus.TIMED_OUT, + context.errorCount, context.warningCount, summary(context, false)); + return BehaviorOutcome.interrupted("timeout"); + } + executeTask(context, adapter, executionContext, task, workspace); + } + return BehaviorOutcome.COMPLETED; + } + + private void executeTask(RunContext context, SkillRuntimeAdapter adapter, + RuntimeExecutionContext executionContext, ValidationTaskSpec task, + Path workspace) { + if (!adapter.supports(task.type())) { + context.tasksFailed++; + context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "ADAPTER_TASK_UNSUPPORTED", + SPEC_FILE, "runtime '" + adapter.agentType() + "' cannot execute " + + task.type() + " task '" + task.name() + "'", null)); + return; + } + if (task.type() == TaskType.SCRIPT && !scriptExists(workspace, task.script())) { + context.tasksFailed++; + context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "TASK_SCRIPT_MISSING", + task.script(), "script file not found in the skill: " + task.script(), null)); + return; + } + + context.tasksRun++; + TaskResult result; + try { + result = adapter.execute(executionContext, task, context.sink()); + } catch (Exception exception) { + context.tasksFailed++; + context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "TASK_FAILED", + SPEC_FILE, "task '" + task.name() + "' failed: " + + truncate(String.valueOf(exception.getMessage()), 500), null)); + return; + } + + List failures = + assertionEvaluator.evaluate(task.safeAssertions(), result); + if (failures.isEmpty()) { + emit(context.runId, ValidationEventType.LOG, "BEHAVIOR", Map.of( + "task", task.name(), "stream", "summary", + "line", "task '" + task.name() + "' passed " + + task.safeAssertions().size() + " assertion(s)")); + return; + } + context.tasksFailed++; + for (AssertionFailure failure : failures) { + context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "ASSERTION_FAILED", + SPEC_FILE, "task '" + task.name() + "': " + failure.describe(), null)); + } + } + + // ---------------------------------------------------------------- events & terminal handling + + /** Emits one event, persists it, and pushes it to live SSE subscribers. */ + private void emit(Long runId, ValidationEventType type, String phase, Map payload) { + ValidationEvent event = runService.appendEvent(runId, type, phase, payload); + broadcaster.publish(runId, event); + } + + /** Appends the RUN_FINISHED event exactly once per run, then completes SSE subscribers. */ + private void emitTerminalOnce(Long runId) { + Object lock = terminalLocks.computeIfAbsent(runId, key -> new Object()); + synchronized (lock) { + for (ValidationEvent event : runService.listEvents(runId, null)) { + if (event.getEventType() == ValidationEventType.RUN_FINISHED) { + broadcaster.completeRun(runId, event); + return; + } + } + ValidationRun run = runService.getRun(runId); + ValidationEvent terminal = runService.appendEvent(runId, + ValidationEventType.RUN_FINISHED, null, Map.of( + "status", run.getStatus().name(), + "errorCount", run.getErrorCount(), + "warningCount", run.getWarningCount())); + broadcaster.completeRun(runId, terminal); + } + } + + // ---------------------------------------------------------------- small helpers + + private boolean scriptExists(Path workspace, String script) { + try { + Path resolved = workspace.resolve(script).normalize(); + return resolved.startsWith(workspace.normalize()) && Files.isRegularFile(resolved); + } catch (Exception exception) { + return false; + } + } + + private SkillRuntimeAdapter findAdapter(String agentType) { + return adapters.stream() + .filter(adapter -> adapter.agentType().equals(agentType)) + .findFirst() + .orElse(null); + } + + private Map summary(RunContext context, boolean behaviorSkipped) { + Map summary = new LinkedHashMap<>(); + summary.put("structureErrors", context.errorCount(ValidationLayer.STRUCTURE)); + summary.put("structureWarnings", context.warningCount(ValidationLayer.STRUCTURE)); + summary.put("configErrors", context.errorCount(ValidationLayer.CONFIG)); + summary.put("configWarnings", context.warningCount(ValidationLayer.CONFIG)); + summary.put("behaviorTasksRun", context.tasksRun); + summary.put("behaviorTasksFailed", context.tasksFailed); + summary.put("behaviorSkipped", behaviorSkipped); + summary.put("errorCount", context.errorCount); + summary.put("warningCount", context.warningCount); + return summary; + } + + private Path workspaceFor(Long runId) { + Path workspace = Path.of(properties.getWorkspaceRoot(), "run-" + runId); + try { + Files.createDirectories(workspace); + } catch (IOException exception) { + throw new IllegalStateException("cannot create validation workspace: " + workspace, exception); + } + return workspace; + } + + private void materializeWorkspace(Path workspace, List entries) throws IOException { + for (PackageEntry entry : entries) { + Path target = workspace.resolve(entry.path()).normalize(); + if (!target.startsWith(workspace.normalize())) { + throw new IOException("draft file escapes workspace: " + entry.path()); + } + Files.createDirectories(target.getParent()); + try (InputStream input = entry.openStream(); + OutputStream output = Files.newOutputStream(target)) { + input.transferTo(output); + } + } + } + + private String readEntryText(List entries, String path) { + return entries.stream() + .filter(entry -> path.equals(entry.path())) + .findFirst() + .map(entry -> new String(entry.content(), StandardCharsets.UTF_8)) + .orElse(null); + } + + private void deleteRecursively(Path root) { + try (var stream = Files.walk(root)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // workspace cleanup is best-effort; the OS temp sweeper is the backstop + } + }); + } catch (IOException ignored) { + // same: cleanup failures must not mask the validation outcome + } + } + + private static String truncate(String value, int limit) { + if (value == null) { + return ""; + } + return value.length() <= limit ? value : value.substring(0, limit) + "…"; + } + + private record BehaviorOutcome(boolean interrupted, String reason) { + static final BehaviorOutcome COMPLETED = new BehaviorOutcome(false, null); + + static BehaviorOutcome interrupted(String reason) { + return new BehaviorOutcome(true, reason); + } + } + + /** Mutable per-run state: findings, counters, and the single-writer event sink. */ + private final class RunContext { + + private final Long runId; + private final List findings = new ArrayList<>(); + private int errorCount; + private int warningCount; + private int tasksRun; + private int tasksFailed; + + RunContext(Long runId) { + this.runId = runId; + } + + int errorCount(ValidationLayer layer) { + return (int) findings.stream() + .filter(finding -> finding.layer() == layer + && finding.severity() == FindingSeverity.ERROR) + .count(); + } + + int warningCount(ValidationLayer layer) { + return (int) findings.stream() + .filter(finding -> finding.layer() == layer + && finding.severity() == FindingSeverity.WARNING) + .count(); + } + + void recordFinding(FindingDraft draft) { + findings.add(draft); + if (draft.severity() == FindingSeverity.ERROR) { + errorCount++; + } else if (draft.severity() == FindingSeverity.WARNING) { + warningCount++; + } + ValidationFinding persisted = runService.recordFinding(runId, draft); + emit(runId, ValidationEventType.FINDING, draft.layer().name(), Map.of( + "findingId", persisted.getId(), + "layer", draft.layer().name(), + "ruleCode", draft.ruleCode(), + "severity", draft.severity().name(), + "filePath", draft.filePath() == null ? "" : draft.filePath(), + "message", truncate(draft.message(), MAX_EVENT_TEXT), + "hasSuggestion", draft.suggestion() != null)); + } + + /** Serialized sink so per-run event sequence numbers cannot race. */ + RuntimeEventSink sink() { + return new RuntimeEventSink() { + @Override + public synchronized void agentMessage(String taskName, String content) { + emit(runId, ValidationEventType.AGENT_MESSAGE, "BEHAVIOR", Map.of( + "task", taskName, "content", truncate(content, MAX_EVENT_TEXT))); + } + + @Override + public synchronized void toolCall(String taskName, String tool, String argumentsJson) { + emit(runId, ValidationEventType.TOOL_CALL, "BEHAVIOR", Map.of( + "task", taskName, "tool", tool, + "arguments", truncate(argumentsJson, MAX_EVENT_TEXT))); + } + + @Override + public synchronized void toolResult(String taskName, String tool, String summaryJson) { + emit(runId, ValidationEventType.TOOL_RESULT, "BEHAVIOR", Map.of( + "task", taskName, "tool", tool, + "summary", truncate(summaryJson, MAX_EVENT_TEXT))); + } + + @Override + public synchronized void log(String taskName, String stream, String line) { + emit(runId, ValidationEventType.LOG, "BEHAVIOR", Map.of( + "task", taskName, "stream", stream, + "line", truncate(line, 2_000))); + } + }; + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java new file mode 100644 index 000000000..197b0c133 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java @@ -0,0 +1,164 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.config.AuthoringProperties; +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * Production backend: the script runs inside a throwaway container with a hard + * isolation profile — no network, memory/CPU/pids caps, read-only root filesystem, + * all capabilities dropped, no-new-privileges, no log persistence. Only the + * validation workspace is mounted (read-write) so scripts can produce artifacts; + * every other path the container sees is immutable or ephemeral tmpfs. + * + *

Network isolation is deliberately not configurable: a validation run must + * never be able to reach other services. The image must provide the configured + * interpreter (the default alpine ships {@code sh}; python/node skills need a + * matching image). + */ +@Component +public class DockerScriptCommandBuilder implements ScriptCommandBuilder { + + /** Mount point of the validation workspace inside the container. */ + public static final String CONTAINER_WORKSPACE = "/workspace"; + + private static final Logger log = LoggerFactory.getLogger(DockerScriptCommandBuilder.class); + private static final Duration PROBE_TIMEOUT = Duration.ofSeconds(5); + private static final Duration CLEANUP_TIMEOUT = Duration.ofSeconds(5); + + private final AuthoringProperties properties; + private volatile Boolean daemonReachable; + + public DockerScriptCommandBuilder(AuthoringProperties properties) { + this.properties = properties; + } + + @Override + public String backend() { + return "docker"; + } + + @Override + public boolean available() { + Boolean cached = daemonReachable; + if (cached == null) { + cached = probeDaemon(); + daemonReachable = cached; + } + return cached; + } + + /** Re-probes the daemon on the next {@link #available()} call. */ + void resetProbe() { + daemonReachable = null; + } + + private boolean probeDaemon() { + try { + Process process = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}") + .start(); + if (!process.waitFor(PROBE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + return false; + } + return process.exitValue() == 0; + } catch (IOException | InterruptedException exception) { + return false; + } + } + + @Override + public ScriptExecutionPlan plan(Path workspace, String interpreter, Path scriptPath, + List args, Map scrubbedEnvironment, + String runTag) { + AuthoringProperties.LocalScript.Docker docker = properties.getLocalScript().getDocker(); + String hostWorkspace = realPath(workspace); + + List command = new ArrayList<>(); + command.add("docker"); + command.add("run"); + command.add("--rm"); + command.add("--name"); + command.add(containerName(runTag)); + command.add("--network"); + command.add("none"); + command.add("--memory"); + command.add(docker.getMemory()); + command.add("--cpus"); + command.add(docker.getCpus()); + command.add("--pids-limit"); + command.add(String.valueOf(docker.getPidsLimit())); + command.add("--read-only"); + command.add("--tmpfs"); + command.add("/tmp:rw,size=" + docker.getTmpfsSize()); + command.add("--cap-drop"); + command.add("ALL"); + command.add("--security-opt"); + command.add("no-new-privileges"); + command.add("--log-driver"); + command.add("none"); + for (Map.Entry entry : scrubbedEnvironment.entrySet()) { + command.add("-e"); + command.add(entry.getKey() + "=" + containerEnvValue(entry.getKey(), entry.getValue())); + } + command.add("-v"); + command.add(hostWorkspace + ":" + CONTAINER_WORKSPACE); + command.add("-w"); + command.add(CONTAINER_WORKSPACE); + command.add(docker.getImage()); + command.add(interpreter); + command.add(containerScriptPath(workspace, scriptPath)); + command.addAll(args); + // the docker CLI needs its own environment to reach the daemon; the script + // only sees the -e variables above, inside the container + return ScriptExecutionPlan.inherited(List.copyOf(command)); + } + + @Override + public void cleanup(String runTag) { + try { + Process process = new ProcessBuilder("docker", "rm", "-f", containerName(runTag)).start(); + if (!process.waitFor(CLEANUP_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + } + } catch (IOException | InterruptedException exception) { + log.warn("Could not force-remove validation container {}: {}", + containerName(runTag), exception.getMessage()); + } + } + + /** Container names allow [a-zA-Z0-9][a-zA-Z0-9_.-]+. */ + static String containerName(String runTag) { + String sanitized = runTag == null ? "unknown" : runTag.replaceAll("[^a-zA-Z0-9_.-]", "-"); + if (sanitized.isEmpty() || !Character.isLetterOrDigit(sanitized.charAt(0))) { + sanitized = "r" + sanitized; + } + return "skillhub-validation-" + sanitized; + } + + private String containerScriptPath(Path workspace, Path scriptPath) { + return CONTAINER_WORKSPACE + "/" + workspace.relativize(scriptPath); + } + + /** HOME points at the workspace; inside the container that is the mount point. */ + private String containerEnvValue(String key, String value) { + return "HOME".equals(key) ? CONTAINER_WORKSPACE : value; + } + + /** Resolves symlinks (e.g. macOS /var/folders → /private/var/folders) for bind mounts. */ + private String realPath(Path workspace) { + try { + return workspace.toRealPath().toString(); + } catch (IOException exception) { + return workspace.toAbsolutePath().toString(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/InlineScriptCommandBuilder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/InlineScriptCommandBuilder.java new file mode 100644 index 000000000..6873649af --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/InlineScriptCommandBuilder.java @@ -0,0 +1,38 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** + * Default backend: the script runs as a plain subprocess of the server process + * with a scrubbed environment. Suitable for local development and CI where the + * drafts are the developer's own; production deployments should switch to the + * docker backend. + */ +@Component +public class InlineScriptCommandBuilder implements ScriptCommandBuilder { + + @Override + public String backend() { + return "inline"; + } + + @Override + public boolean available() { + return true; + } + + @Override + public ScriptExecutionPlan plan(Path workspace, String interpreter, Path scriptPath, + List args, Map scrubbedEnvironment, + String runTag) { + List command = new ArrayList<>(); + command.add(interpreter); + command.add(scriptPath.toString()); + command.addAll(args); + return new ScriptExecutionPlan(List.copyOf(command), Map.copyOf(scrubbedEnvironment)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/LocalScriptRuntimeAdapter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/LocalScriptRuntimeAdapter.java new file mode 100644 index 000000000..df1f1ae7f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/LocalScriptRuntimeAdapter.java @@ -0,0 +1,237 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext; +import com.iflytek.skillhub.domain.authoring.runtime.SkillRuntimeAdapter; +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * Behavior-layer adapter that executes the skill's own scripts in an isolated working + * directory with a scrubbed environment. Only whitelisted interpreters may run and only + * environment variables named in the binding's {@code envAllowlist} pass through. + * + *

Where the process actually runs is delegated to a {@link ScriptCommandBuilder}: + * the inline backend spawns the interpreter on the server host, the docker backend + * wraps it in a locked-down container. Everything else (streaming, buffering, timeout, + * cancel) is backend-independent. + */ +@Component +public class LocalScriptRuntimeAdapter implements SkillRuntimeAdapter { + + private static final int MAX_STREAM_BUFFER = 256 * 1024; + private static final long POLL_SLICE_MS = 200; + + private final AuthoringProperties properties; + private final Map commandBuilders; + + public LocalScriptRuntimeAdapter(AuthoringProperties properties, + List commandBuilders) { + this.properties = properties; + this.commandBuilders = commandBuilders.stream() + .collect(Collectors.toUnmodifiableMap(ScriptCommandBuilder::backend, + Function.identity())); + } + + @Override + public String agentType() { + return "local-script"; + } + + @Override + public boolean supports(TaskType taskType) { + return taskType == TaskType.SCRIPT; + } + + @Override + public boolean enabled() { + return properties.getLocalScript().isEnabled() && commandBuilder().available(); + } + + @Override + public TaskResult execute(RuntimeExecutionContext context, ValidationTaskSpec task, + RuntimeEventSink sink) throws Exception { + ScriptCommandBuilder builder = commandBuilder(); + if (!builder.available()) { + throw new IllegalStateException( + "script execution backend '" + builder.backend() + "' is not available" + + " (is the docker daemon reachable?)"); + } + String interpreter = resolveInterpreter(context); + Path scriptPath = resolveInsideWorkingDirectory(context.workingDirectory(), task.script()); + String runTag = context.runId() + "-" + task.name(); + Map scrubbedEnvironment = scrubEnvironment(context); + + ScriptCommandBuilder.ScriptExecutionPlan plan = builder.plan( + context.workingDirectory(), interpreter, scriptPath, + task.safeArgs(), scrubbedEnvironment, runTag); + + sink.toolCall(task.name(), interpreter + " [" + builder.backend() + "]", + String.join(" ", task.safeArgs())); + + ProcessBuilder processBuilder = new ProcessBuilder(plan.command()); + processBuilder.directory(context.workingDirectory().toFile()); + processBuilder.redirectErrorStream(false); + if (plan.environment() != null) { + processBuilder.environment().clear(); + processBuilder.environment().putAll(plan.environment()); + } + + Process process = processBuilder.start(); + BoundedBuffer stdout = new BoundedBuffer(MAX_STREAM_BUFFER); + BoundedBuffer stderr = new BoundedBuffer(MAX_STREAM_BUFFER); + Thread stdoutReader = streamReader(process.getInputStream(), "stdout", task.name(), stdout, sink); + Thread stderrReader = streamReader(process.getErrorStream(), "stderr", task.name(), stderr, sink); + stdoutReader.start(); + stderrReader.start(); + + Integer exitCode = await(process, task, context, builder, runTag); + stdoutReader.join(TimeUnit.SECONDS.toMillis(5)); + stderrReader.join(TimeUnit.SECONDS.toMillis(5)); + + sink.toolResult(task.name(), interpreter, + "{\"backend\":\"" + builder.backend() + "\",\"exitCode\":" + exitCode + + ",\"stdoutBytes\":" + stdout.size() + + ",\"stderrBytes\":" + stderr.size() + "}"); + + return TaskResult.ofScript(exitCode, stdout.content(), stderr.content(), 1, + new WorkingDirectoryArtifacts(context.workingDirectory())); + } + + private ScriptCommandBuilder commandBuilder() { + String mode = properties.getLocalScript().getExecutionMode() + == AuthoringProperties.ScriptExecutionMode.DOCKER ? "docker" : "inline"; + ScriptCommandBuilder builder = commandBuilders.get(mode); + if (builder == null) { + throw new IllegalStateException( + "no script command builder registered for execution mode: " + mode); + } + return builder; + } + + private String resolveInterpreter(RuntimeExecutionContext context) { + String configured = context.configString("interpreter"); + if (configured != null && !configured.isBlank()) { + return configured; + } + return "sh"; + } + + private Map scrubEnvironment(RuntimeExecutionContext context) { + Map environment = new java.util.LinkedHashMap<>(); + environment.put("PATH", System.getenv().getOrDefault("PATH", "/usr/bin:/bin")); + environment.put("HOME", context.workingDirectory().toString()); + environment.put("LANG", "C.UTF-8"); + Object allowlist = context.safeAdapterConfig().get("envAllowlist"); + if (allowlist instanceof List entries) { + for (Object entry : entries) { + if (entry != null) { + String value = System.getenv(entry.toString()); + if (value != null) { + environment.put(entry.toString(), value); + } + } + } + } + return environment; + } + + /** + * Waits for process exit while honoring the task timeout and the cooperative cancel + * signal. Returns the exit code, or {@code null} when the process had to be killed; + * abnormal exits also trigger the builder's cleanup (the docker backend must + * force-remove the container because killing the CLI client does not stop it). + */ + private Integer await(Process process, ValidationTaskSpec task, RuntimeExecutionContext context, + ScriptCommandBuilder builder, String runTag) throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofMillis(task.timeoutMs()).toNanos(); + while (true) { + if (process.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) { + return process.exitValue(); + } + if (context.cancellation() != null && context.cancellation().isCancelRequested()) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + builder.cleanup(runTag); + return null; + } + if (System.nanoTime() > deadline) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + builder.cleanup(runTag); + throw new IllegalStateException( + "task '" + task.name() + "' exceeded its timeout of " + task.timeoutMs() + " ms"); + } + } + } + + private Thread streamReader(java.io.InputStream stream, String streamName, String taskName, + BoundedBuffer buffer, RuntimeEventSink sink) { + Thread thread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + buffer.append(line); + sink.log(taskName, streamName, line); + } + } catch (IOException ignored) { + // stream closed when the process dies; buffered content is kept + } + }, "validation-stream-" + streamName); + thread.setDaemon(true); + return thread; + } + + static Path resolveInsideWorkingDirectory(Path workingDirectory, String relativePath) { + Path resolved = workingDirectory.resolve(relativePath).normalize(); + if (!resolved.startsWith(workingDirectory.normalize())) { + throw new IllegalArgumentException( + "path escapes the working directory: " + relativePath); + } + return resolved; + } + + /** Cap-aware single-stream buffer: keeps the head of the output for findings. */ + private static final class BoundedBuffer { + private final StringBuilder builder = new StringBuilder(); + private final int limit; + + BoundedBuffer(int limit) { + this.limit = limit; + } + + synchronized void append(String line) { + if (builder.length() >= limit) { + return; + } + if (builder.length() > 0) { + builder.append('\n'); + } + builder.append(line, 0, Math.min(line.length(), limit - builder.length())); + } + + synchronized String content() { + return builder.toString(); + } + + synchronized int size() { + return builder.length(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapter.java new file mode 100644 index 000000000..f0f4124df --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapter.java @@ -0,0 +1,345 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext; +import com.iflytek.skillhub.domain.authoring.runtime.SkillRuntimeAdapter; +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import com.iflytek.skillhub.domain.authoring.service.AuthoringSecurityPolicy; +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import com.iflytek.skillhub.service.authoring.mcp.HttpMcpClient; +import com.iflytek.skillhub.service.authoring.mcp.McpClient; +import com.iflytek.skillhub.service.authoring.mcp.McpClientFactory; +import com.iflytek.skillhub.service.authoring.mcp.McpTool; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * Behavior-layer adapter for prompt tasks: an agentic loop against an + * OpenAI-compatible chat-completion endpoint. Tools declared on the binding's MCP + * servers are discovered live (initialize + tools/list), filtered by the server's + * toolFilters and the binding's toolAllowlist, exposed to the model as function + * tools, and executed for real when the model calls them — every chat round and + * every tool invocation/reply lands in the run's event trace. The API key is + * resolved from server-side configuration only; runtime bindings reference + * endpoints and models, never credentials. + */ +@Component +public class OpenAiCompatibleRuntimeAdapter implements SkillRuntimeAdapter { + + private static final String SKILL_MD = "SKILL.md"; + /** Cap on a single tool output fed back into the model context. */ + private static final int MAX_TOOL_OUTPUT_CHARS = 8_000; + /** Cap on tool discovery (initialize + tools/list) regardless of task timeout. */ + private static final Duration DISCOVERY_TIMEOUT = Duration.ofSeconds(30); + + private final AuthoringProperties properties; + private final ObjectMapper objectMapper; + private final McpClientFactory mcpClientFactory; + private final AuthoringSecurityPolicy securityPolicy; + private final HttpClient httpClient; + + public OpenAiCompatibleRuntimeAdapter(AuthoringProperties properties, ObjectMapper objectMapper, + McpClientFactory mcpClientFactory, + AuthoringSecurityPolicy securityPolicy) { + this.properties = properties; + this.objectMapper = objectMapper; + this.mcpClientFactory = mcpClientFactory; + this.securityPolicy = securityPolicy; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + // never follow redirects: a public endpoint answering 302 towards + // a link-local target must not become an SSRF bypass + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + } + + @Override + public String agentType() { + return "openai-compatible"; + } + + @Override + public boolean supports(TaskType taskType) { + return taskType == TaskType.PROMPT; + } + + @Override + public boolean enabled() { + return properties.getOpenAiCompatible().isEnabled(); + } + + /** One tool the model may call, bound to the client that can execute it. */ + private record ExposedTool(McpClient client, String server, McpTool tool) { + } + + @Override + public TaskResult execute(RuntimeExecutionContext context, ValidationTaskSpec task, + RuntimeEventSink sink) throws Exception { + AuthoringProperties.OpenAiCompatible config = properties.getOpenAiCompatible(); + String endpoint = firstNonBlank(context.configString("endpoint"), config.getDefaultEndpoint()); + String model = firstNonBlank(context.configString("model"), config.getDefaultModel()); + if (endpoint == null || model == null) { + throw new IllegalStateException( + "openai-compatible runtime requires endpoint and model (binding config or server defaults)"); + } + // requests carry the server-side API key, so the destination must pass + // the SSRF guard before any credential leaves the host + String endpointRejection = securityPolicy.endpointRejection( + endpoint, AuthoringSecurityPolicy.EndpointUse.LLM_API); + if (endpointRejection != null) { + throw new IllegalStateException( + "openai-compatible endpoint is not allowed: " + endpointRejection); + } + String url = endpoint.endsWith("/") ? endpoint + "chat/completions" : endpoint + "/chat/completions"; + Duration requestTimeout = Duration.ofMillis(Math.max(task.timeoutMs(), 1_000)); + + List clients = new ArrayList<>(); + Map exposedTools = discoverTools(context, task, sink, clients); + try { + ArrayNode messages = objectMapper.createArrayNode(); + messages.addObject().put("role", "system").put("content", readSkillMd(context.workingDirectory())); + messages.addObject().put("role", "user").put("content", task.prompt()); + + int executedToolCalls = 0; + String content = ""; + int maxRounds = Math.max(config.getMaxToolRounds(), 1); + for (int round = 1; round <= maxRounds; round++) { + if (context.cancellation().isCancelRequested()) { + throw new IllegalStateException("task '" + task.name() + "' cancelled"); + } + + ObjectNode requestBody = chatRequest(model, context, messages, exposedTools, round); + sink.toolCall(task.name(), "openai-chat", requestBody.get("meta").toString()); + JsonNode message = postChatCompletion(url, config, requestBody, requestTimeout); + + content = message.path("content").asText(""); + JsonNode toolCalls = message.path("tool_calls"); + if (toolCalls.isEmpty() || exposedTools.isEmpty()) { + break; // final answer (or nothing we can execute) + } + + // record the assistant turn verbatim, then append one tool reply per call + ObjectNode assistantMessage = objectMapper.createObjectNode(); + assistantMessage.put("role", "assistant"); + if (!content.isEmpty()) { + assistantMessage.put("content", content); + } + HttpMcpClient.attachToolCalls(objectMapper, assistantMessage, toolCalls); + messages.add(assistantMessage); + + for (JsonNode call : toolCalls) { + String callId = call.path("id").asText("call-" + executedToolCalls); + String functionName = call.path("function").path("name").asText(""); + String argumentsJson = call.path("function").path("arguments").asText("{}"); + ExposedTool exposed = exposedTools.get(functionName); + if (exposed == null) { + appendToolReply(messages, callId, "error: unknown tool '" + functionName + "'"); + sink.toolResult(task.name(), "mcp:" + functionName, + "{\"error\":\"unknown tool\"}"); + continue; + } + sink.toolCall(task.name(), "mcp:" + exposed.server() + "/" + exposed.tool().name(), + argumentsJson); + String reply; + try { + Map arguments = parseArguments(argumentsJson); + String output = exposed.client().callTool( + exposed.tool().name(), arguments, requestTimeout); + reply = truncate(output, MAX_TOOL_OUTPUT_CHARS); + executedToolCalls++; + sink.toolResult(task.name(), "mcp:" + exposed.server() + "/" + exposed.tool().name(), + "{\"ok\":true,\"length\":" + output.length() + "}"); + } catch (Exception exception) { + reply = "error: tool call failed: " + exception.getMessage(); + sink.toolResult(task.name(), "mcp:" + exposed.server() + "/" + exposed.tool().name(), + "{\"error\":" + jsonString(String.valueOf(exception.getMessage())) + "}"); + } + appendToolReply(messages, callId, reply); + } + if (round == maxRounds) { + sink.log(task.name(), "summary", + "round limit (" + maxRounds + ") reached while the model still requests tools"); + } + } + + sink.agentMessage(task.name(), truncate(content, 4_000)); + return TaskResult.ofPrompt(content, executedToolCalls, + new WorkingDirectoryArtifacts(context.workingDirectory())); + } finally { + clients.forEach(McpClient::close); + } + } + + // ---------------------------------------------------------------- tool discovery + + /** + * Connects to every declared MCP server, lists its tools, and keeps the ones + * allowed by the server's toolFilters and the binding's global toolAllowlist + * (empty allowlist = unrestricted; entries match the bare tool name or + * "server.tool"). Keys of the returned map are the function names exposed to + * the model. Every client created along the way is appended to {@code clients} + * so the caller can close them all — even servers whose tools were entirely + * filtered out. + */ + private Map discoverTools(RuntimeExecutionContext context, + ValidationTaskSpec task, RuntimeEventSink sink, + List clients) { + Map exposed = new LinkedHashMap<>(); + List allowlist = context.safeToolAllowlist(); + for (Map server : context.safeMcpServers()) { + String serverName = String.valueOf(server.get("name")); + Set toolFilters = server.get("toolFilters") instanceof List filters + ? filters.stream().map(String::valueOf).collect(Collectors.toSet()) + : Set.of(); + List tools; + McpClient client; + try { + client = mcpClientFactory.create(server); + clients.add(client); + tools = client.listTools(DISCOVERY_TIMEOUT); + } catch (Exception exception) { + // the config-layer probe already reported unreachable servers; skip quietly + sink.log(task.name(), "mcp", "server '" + serverName + "' unavailable during task: " + + exception.getMessage()); + continue; + } + for (McpTool tool : tools) { + if (!toolFilters.isEmpty() && !toolFilters.contains(tool.name())) { + continue; + } + if (!allowlist.isEmpty() && !allowlist.contains(tool.name()) + && !allowlist.contains(serverName + "." + tool.name())) { + continue; + } + exposed.put(exposedName(exposed.keySet(), serverName, tool.name()), + new ExposedTool(client, serverName, tool)); + } + } + return exposed; + } + + /** Function names must match ^[a-zA-Z0-9_-]{1,64}$; MCP names may contain dots. */ + private String exposedName(Set taken, String serverName, String toolName) { + String candidate = (serverName + "__" + toolName).replaceAll("[^a-zA-Z0-9_-]", "_"); + if (candidate.length() > 64) { + candidate = candidate.substring(0, 64); + } + String unique = candidate; + int suffix = 2; + while (taken.contains(unique)) { + String tail = "_" + suffix++; + unique = candidate.substring(0, 64 - tail.length()) + tail; + } + return unique; + } + + // ---------------------------------------------------------------- chat protocol + + private ObjectNode chatRequest(String model, RuntimeExecutionContext context, ArrayNode messages, + Map exposedTools, int round) { + ObjectNode body = objectMapper.createObjectNode(); + body.put("model", model); + body.put("stream", false); + Object temperature = context.safeAdapterConfig().get("temperature"); + if (temperature instanceof Number number) { + body.put("temperature", number.doubleValue()); + } + body.set("messages", messages); + if (!exposedTools.isEmpty()) { + ArrayNode tools = body.putArray("tools"); + exposedTools.forEach((exposedName, tool) -> tools.add( + HttpMcpClient.toFunctionSchema(objectMapper, tool.tool(), exposedName))); + } + // trace metadata; removed before the body reaches the endpoint + ObjectNode meta = objectMapper.createObjectNode(); + meta.put("model", model); + meta.put("round", round); + meta.put("messages", messages.size()); + meta.put("tools", exposedTools.size()); + body.set("meta", meta); + return body; + } + + /** Posts one chat-completion request and returns the first choice's message. */ + private JsonNode postChatCompletion(String url, AuthoringProperties.OpenAiCompatible config, + ObjectNode requestBody, Duration timeout) throws Exception { + requestBody.remove("meta"); // trace-only field must not reach the endpoint + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(timeout) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody.toString(), StandardCharsets.UTF_8)); + if (config.getApiKey() != null && !config.getApiKey().isBlank()) { + requestBuilder.header("Authorization", "Bearer " + config.getApiKey()); + } + HttpResponse response = + httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream()); + String body = HttpMcpClient.readBodyCapped(response.body(), HttpMcpClient.MAX_RESPONSE_BYTES); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("chat completion failed with HTTP " + response.statusCode() + + ": " + truncate(body, 500)); + } + return objectMapper.readTree(body).path("choices").path(0).path("message"); + } + + private void appendToolReply(ArrayNode messages, String toolCallId, String content) { + messages.add(HttpMcpClient.toolResponseMessage(objectMapper, toolCallId, content)); + } + + @SuppressWarnings("unchecked") + private Map parseArguments(String argumentsJson) { + try { + return objectMapper.readValue(argumentsJson, Map.class); + } catch (Exception exception) { + return new HashMap<>(); + } + } + + private String jsonString(String value) { + return objectMapper.valueToTree(value == null ? "" : value).toString(); + } + + private String readSkillMd(Path workingDirectory) throws IOException { + Path skillMd = workingDirectory.resolve(SKILL_MD); + if (Files.isRegularFile(skillMd)) { + return Files.readString(skillMd, StandardCharsets.UTF_8); + } + return "No SKILL.md is present in this draft."; + } + + private static String firstNonBlank(String primary, String fallback) { + if (primary != null && !primary.isBlank()) { + return primary; + } + return fallback == null || fallback.isBlank() ? null : fallback; + } + + private static String truncate(String value, int limit) { + if (value == null) { + return ""; + } + return value.length() <= limit ? value : value.substring(0, limit) + "…"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/ScriptCommandBuilder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/ScriptCommandBuilder.java new file mode 100644 index 000000000..c257be56c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/ScriptCommandBuilder.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * Builds the process command for one behavior-task script execution. Two backends + * ship: {@code inline} runs the script as a subprocess on the server host (local + * development only); {@code docker} runs it inside a locked-down container (no + * network, memory/CPU/pids caps, read-only root filesystem) for production. + */ +public interface ScriptCommandBuilder { + + /** Backend identifier matching {@code skillhub.authoring.local-script.execution-mode}. */ + String backend(); + + /** Whether the backend can execute right now (for docker: daemon reachable). */ + boolean available(); + + /** + * Builds the execution plan for one task. {@code scrubbedEnvironment} carries + * exactly the variables the script may see; backends decide how to deliver + * them (inline: process environment; docker: {@code -e} flags, with the + * workspace path remapped to its mount point inside the container). + * + * @param runTag stable per-task tag (run id + task name), used for container + * naming so abnormal exits can be force-removed + */ + ScriptExecutionPlan plan(Path workspace, String interpreter, Path scriptPath, + List args, Map scrubbedEnvironment, + String runTag); + + /** + * Best-effort cleanup after the wrapping process was killed (timeout or + * cancel). Killing the {@code docker run} client does not stop the container, + * so the docker backend force-removes it here. + */ + default void cleanup(String runTag) { + } + + /** + * Command line plus environment. A null environment means the spawned client + * (e.g. the docker CLI) needs the parent's environment intact; the isolation + * then lives in the container flags instead. + */ + record ScriptExecutionPlan(List command, Map environment) { + + public static ScriptExecutionPlan inherited(List command) { + return new ScriptExecutionPlan(command, null); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/WorkingDirectoryArtifacts.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/WorkingDirectoryArtifacts.java new file mode 100644 index 000000000..25d6ae65f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/WorkingDirectoryArtifacts.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +/** + * Reads artifact files from a run's isolated working directory, rejecting paths that + * would escape it. Shared by all runtime adapters. + */ +record WorkingDirectoryArtifacts(Path workingDirectory) implements TaskResult.ArtifactResolver { + + @Override + public Optional read(String relativePath) { + try { + Path resolved = workingDirectory.resolve(relativePath).normalize(); + if (!resolved.startsWith(workingDirectory.normalize())) { + return Optional.empty(); + } + if (!Files.isRegularFile(resolved)) { + return Optional.empty(); + } + return Optional.of(Files.readAllBytes(resolved)); + } catch (IllegalArgumentException | IOException exception) { + return Optional.empty(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClient.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClient.java new file mode 100644 index 000000000..47c4ec5ff --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClient.java @@ -0,0 +1,264 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * MCP client for the streamable-HTTP transport (also used for legacy SSE + * endpoints that accept plain JSON-RPC POSTs). Each request is one JSON-RPC + * POST; responses may arrive as a bare JSON object or as a text/event-stream + * body whose data lines carry the JSON-RPC message. Response bodies are + * size-capped so a hostile endpoint cannot exhaust server memory. + */ +public class HttpMcpClient implements McpClient { + + public static final String PROTOCOL_VERSION = "2024-11-05"; + public static final String CLIENT_NAME = "skillhub-authoring"; + /** One HTTP response body may not exceed this many bytes. */ + public static final int MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + + private final String serverName; + private final String endpoint; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final Map extraHeaders; + private int nextId = 1; + private String sessionId; + + public HttpMcpClient(String serverName, String endpoint, HttpClient httpClient, + ObjectMapper objectMapper, Map extraHeaders) { + this.serverName = serverName; + this.endpoint = endpoint; + this.httpClient = httpClient; + this.objectMapper = objectMapper; + this.extraHeaders = Map.copyOf(extraHeaders); + } + + @Override + public String serverName() { + return serverName; + } + + @Override + public List listTools(Duration timeout) throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("protocolVersion", PROTOCOL_VERSION); + params.set("capabilities", objectMapper.createObjectNode()); + ObjectNode clientInfo = params.putObject("clientInfo"); + clientInfo.put("name", CLIENT_NAME); + clientInfo.put("version", "1.0"); + // the server may assign a session id here; request() captures it for later calls + request("initialize", params, timeout); + // notify the server the handshake is complete; the response is irrelevant + notification("notifications/initialized", timeout); + + JsonNode tools = request("tools/list", objectMapper.createObjectNode(), timeout); + List discovered = new ArrayList<>(); + for (JsonNode tool : tools.path("tools")) { + discovered.add(new McpTool( + tool.path("name").asText(""), + tool.path("description").asText(""), + tool.path("inputSchema"))); + } + return discovered; + } + + @Override + public String callTool(String toolName, Map arguments, Duration timeout) + throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("name", toolName); + params.set("arguments", objectMapper.valueToTree(arguments == null ? Map.of() : arguments)); + JsonNode result = request("tools/call", params, timeout); + if (result.path("isError").asBoolean(false)) { + throw new IOException("MCP tool '" + toolName + "' returned an error result on server '" + + serverName + "'"); + } + StringBuilder content = new StringBuilder(); + for (JsonNode part : result.path("content")) { + if ("text".equals(part.path("type").asText())) { + if (content.length() > 0) { + content.append('\n'); + } + content.append(part.path("text").asText("")); + } + } + return content.toString(); + } + + @Override + public void close() { + // HTTP transport is stateless from this client's point of view + } + + private JsonNode request(String method, ObjectNode params, Duration timeout) throws Exception { + ObjectNode request = objectMapper.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", nextId++); + request.put("method", method); + request.set("params", params); + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(endpoint)) + .timeout(timeout) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(request.toString(), StandardCharsets.UTF_8)); + extraHeaders.forEach(builder::header); + if (sessionId != null) { + builder.header("Mcp-Session-Id", sessionId); + } + + HttpResponse response; + try { + response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()); + } catch (IOException exception) { + throw new IOException("MCP server '" + serverName + "' request failed (" + + method + "): " + exception, exception); + } + String body = readBodyCapped(response.body(), MAX_RESPONSE_BYTES); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("MCP server '" + serverName + "' returned HTTP " + + response.statusCode() + " for " + method + ": " + truncate(body, 200)); + } + String sessionHeader = response.headers().firstValue("Mcp-Session-Id").orElse(null); + if (sessionHeader != null) { + sessionId = sessionHeader; + } + JsonNode message = parseMessage(body, request.path("id").asInt()); + if (message.has("error")) { + throw new IOException("MCP server '" + serverName + "' error on " + method + ": " + + message.path("error").path("message").asText()); + } + return message.path("result"); + } + + private void notification(String method, Duration timeout) { + try { + ObjectNode notification = objectMapper.createObjectNode(); + notification.put("jsonrpc", "2.0"); + notification.put("method", method); + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(endpoint)) + .timeout(timeout) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(notification.toString(), + StandardCharsets.UTF_8)); + extraHeaders.forEach(builder::header); + if (sessionId != null) { + builder.header("Mcp-Session-Id", sessionId); + } + httpClient.send(builder.build(), HttpResponse.BodyHandlers.discarding()); + } catch (Exception ignored) { + // notifications are best-effort; servers that reject them still work + } + } + + /** Accepts either a bare JSON-RPC response or an SSE body with data lines. */ + private JsonNode parseMessage(String body, int requestId) throws IOException { + if (body == null || body.isBlank()) { + throw new IOException("MCP server '" + serverName + "' returned an empty body"); + } + String text = body.trim(); + if (text.startsWith("{")) { + return objectMapper.readTree(text); + } + for (String line : body.split("\n")) { + String trimmed = line.trim(); + if (!trimmed.startsWith("data:")) { + continue; + } + String payload = trimmed.substring("data:".length()).trim(); + if (payload.isEmpty()) { + continue; + } + JsonNode node = objectMapper.readTree(payload); + if (node.path("id").asInt(-1) == requestId) { + return node; + } + } + throw new IOException("MCP server '" + serverName + + "' response did not contain a result for request " + requestId); + } + + /** Helper for tests and callers that need the OpenAI-style tool schema. */ + public static ObjectNode toFunctionSchema(ObjectMapper objectMapper, McpTool tool) { + return toFunctionSchema(objectMapper, tool, tool.name()); + } + + /** Same, under the function name the caller exposes to the model. */ + public static ObjectNode toFunctionSchema(ObjectMapper objectMapper, McpTool tool, + String functionName) { + ObjectNode function = objectMapper.createObjectNode(); + function.put("name", functionName); + function.put("description", tool.description() == null ? "" : tool.description()); + function.set("parameters", tool.inputSchema() == null || tool.inputSchema().isNull() + ? objectMapper.createObjectNode() + : tool.inputSchema()); + ObjectNode wrapper = objectMapper.createObjectNode(); + wrapper.put("type", "function"); + wrapper.set("function", function); + return wrapper; + } + + /** Helper: builds the messages-array tool role entry for chat completions. */ + public static ObjectNode toolResponseMessage(ObjectMapper objectMapper, String toolCallId, + String content) { + ObjectNode message = objectMapper.createObjectNode(); + message.put("role", "tool"); + message.put("tool_call_id", toolCallId); + message.put("content", content); + return message; + } + + /** Helper: attaches tool call requests to an assistant message. */ + public static void attachToolCalls(ObjectMapper objectMapper, ObjectNode assistantMessage, + JsonNode toolCalls) { + ArrayNode calls = assistantMessage.putArray("tool_calls"); + for (JsonNode call : toolCalls) { + calls.add(call.deepCopy()); + } + } + + /** + * Reads a response body fully into a UTF-8 string, refusing bodies beyond + * {@code maxBytes}: a hostile endpoint must not be able to buffer an + * unbounded response in server memory. + */ + public static String readBodyCapped(InputStream body, int maxBytes) throws IOException { + try (body) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int read; + while ((read = body.read(chunk)) != -1) { + if (buffer.size() + read > maxBytes) { + throw new IOException("response body exceeds " + maxBytes + " bytes"); + } + buffer.write(chunk, 0, read); + } + return buffer.toString(StandardCharsets.UTF_8); + } + } + + private static String truncate(String value, int limit) { + if (value == null) { + return ""; + } + return value.length() <= limit ? value : value.substring(0, limit) + "…"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClient.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClient.java new file mode 100644 index 000000000..870c2f2fc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClient.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * A live connection to one MCP server declared in a runtime binding. Implementations + * speak the MCP JSON-RPC protocol (initialize, tools/list, tools/call) over their + * transport and are closed after use. + */ +public interface McpClient extends AutoCloseable { + + /** Display name from the binding declaration, used in events and findings. */ + String serverName(); + + /** Performs the initialize handshake and a tools/list; returns the offered tools. */ + List listTools(Duration timeout) throws Exception; + + /** + * Executes one tool call and returns its text content. Implementations must + * surface MCP-level errors (isError or JSON-RPC error) as exceptions. + */ + String callTool(String toolName, Map arguments, Duration timeout) + throws Exception; + + @Override + void close(); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactory.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactory.java new file mode 100644 index 000000000..36a080aeb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactory.java @@ -0,0 +1,159 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.service.AuthoringSecurityPolicy; +import java.io.IOException; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.springframework.stereotype.Component; + +/** + * Builds {@link McpClient} instances from the runtime binding's MCP server + * declarations. Transport "http"/"sse" yield an HTTP JSON-RPC client; "stdio" + * spawns the declared command with only the envRefs-named variables from the + * server process environment (the only way credentials reach an MCP server). + * + *

Every declaration is re-checked here against the + * {@link AuthoringSecurityPolicy} even though the binding validator already + * rejected unsafe saves: this is the connect-time enforcement point that also + * covers bindings persisted by older deployments. In docker execution mode, + * stdio servers run inside the same locked-down container profile as + * validation scripts — no network, resource caps, read-only rootfs — and the + * container is force-removed when the client closes. + */ +@Component +public class McpClientFactory { + + private final ObjectMapper objectMapper; + private final AuthoringProperties properties; + private final AuthoringSecurityPolicy securityPolicy; + private final HttpClient httpClient; + + public McpClientFactory(ObjectMapper objectMapper, AuthoringProperties properties, + AuthoringSecurityPolicy securityPolicy) { + this.objectMapper = objectMapper; + this.properties = properties; + this.securityPolicy = securityPolicy; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + // redirects must never be followed: a public URL answering 302 + // towards a link-local target is the classic SSRF bypass + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + } + + /** @param server declaration map: name, transport, endpoint|command, toolFilters, envRefs */ + public McpClient create(Map server) throws IOException { + String name = String.valueOf(server.get("name")); + String transport = String.valueOf(server.get("transport")); + if ("http".equals(transport) || "sse".equals(transport)) { + String endpoint = String.valueOf(server.get("endpoint")); + String rejection = securityPolicy.endpointRejection( + endpoint, AuthoringSecurityPolicy.EndpointUse.MCP_SERVER); + if (rejection != null) { + throw new IOException("MCP server '" + name + "' endpoint rejected: " + rejection); + } + return new HttpMcpClient(name, endpoint, httpClient, objectMapper, Map.of()); + } + if ("stdio".equals(transport)) { + if (!securityPolicy.stdioTransportAllowed()) { + throw new IOException("MCP server '" + name + + "': stdio transport is disabled (skillhub.authoring.mcp.stdio-enabled)"); + } + List command = commandOf(server); + Map environment = environmentOf(server); + if (properties.getLocalScript().getExecutionMode() + == AuthoringProperties.ScriptExecutionMode.DOCKER) { + String container = containerName(name); + return new StdioMcpClient(name, dockerWrappedCommand(container, command, environment), + Map.of(), objectMapper, List.of("docker", "rm", "-f", container), true); + } + return new StdioMcpClient(name, command, environment, objectMapper); + } + throw new IllegalArgumentException( + "MCP server '" + name + "' has unsupported transport: " + transport); + } + + /** + * The docker argv that isolates a stdio MCP server: throwaway container, + * no network, resource caps, read-only rootfs, all capabilities dropped. + * Environment variables travel as explicit {@code -e} flags so the docker + * CLI can keep its own environment (needed to reach the daemon). + */ + List dockerWrappedCommand(String container, List command, + Map environment) { + AuthoringProperties.Mcp.Docker docker = properties.getMcp().getDocker(); + List wrapped = new ArrayList<>(); + wrapped.add("docker"); + wrapped.add("run"); + wrapped.add("--rm"); + wrapped.add("--name"); + wrapped.add(container); + wrapped.add("--network"); + wrapped.add("none"); + wrapped.add("--memory"); + wrapped.add(docker.getMemory()); + wrapped.add("--cpus"); + wrapped.add(docker.getCpus()); + wrapped.add("--pids-limit"); + wrapped.add(String.valueOf(docker.getPidsLimit())); + wrapped.add("--read-only"); + wrapped.add("--tmpfs"); + wrapped.add("/tmp:rw,size=" + docker.getTmpfsSize()); + wrapped.add("--cap-drop"); + wrapped.add("ALL"); + wrapped.add("--security-opt"); + wrapped.add("no-new-privileges"); + wrapped.add("--log-driver"); + wrapped.add("none"); + for (Map.Entry entry : environment.entrySet()) { + wrapped.add("-e"); + wrapped.add(entry.getKey() + "=" + entry.getValue()); + } + wrapped.add(docker.getImage()); + wrapped.addAll(command); + return List.copyOf(wrapped); + } + + /** Container names allow [a-zA-Z0-9][a-zA-Z0-9_.-]+ and must be unique per client. */ + static String containerName(String serverName) { + String sanitized = serverName == null ? "unknown" : serverName.replaceAll("[^a-zA-Z0-9_.-]", "-"); + if (sanitized.isEmpty() || !Character.isLetterOrDigit(sanitized.charAt(0))) { + sanitized = "m" + sanitized; + } + return "skillhub-mcp-" + sanitized + "-" + UUID.randomUUID().toString().substring(0, 8); + } + + /** Splits the command on whitespace; no shell is involved. */ + private static List commandOf(Map server) { + String command = String.valueOf(server.get("command")); + return List.of(command.trim().split("\\s+")); + } + + /** + * Only envRefs-named variables from the server environment are passed + * through, and only when the security policy allowlists them. + */ + Map environmentOf(Map server) { + Map environment = new HashMap<>(); + if (server.get("envRefs") instanceof List refs) { + for (Object ref : refs) { + String name = String.valueOf(ref); + if (!securityPolicy.envRefAllowed(name)) { + continue; + } + String value = System.getenv(name); + if (value != null) { + environment.put(name, value); + } + } + } + return environment; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeService.java new file mode 100644 index 000000000..b3c902d39 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeService.java @@ -0,0 +1,92 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * CONFIG-layer liveness probe for declared MCP servers: connects, completes the + * initialize handshake, lists tools, and reports unreachable servers and + * toolFilters that reference tools the server does not expose. This is what turns + * an MCP declaration from "syntactically valid" into "verified working before + * the behavior layer relies on it". + */ +@Component +public class McpProbeService { + + private static final Set KNOWN_TRANSPORTS = Set.of("http", "sse", "stdio"); + + private final McpClientFactory clientFactory; + + public McpProbeService(McpClientFactory clientFactory) { + this.clientFactory = clientFactory; + } + + /** One server's probe outcome: either the discovered tool names or the failure. */ + public record ServerProbe(String server, boolean connected, List tools, String error) { + static ServerProbe connected(String server, List tools) { + return new ServerProbe(server, true, List.copyOf(tools), null); + } + + static ServerProbe failed(String server, String error) { + return new ServerProbe(server, false, List.of(), error); + } + } + + /** All probes plus the findings the orchestrator should record. */ + public record ProbeReport(List servers, List findings) { + } + + public ProbeReport probe(List> mcpServers, java.time.Duration timeout) { + List probes = new ArrayList<>(); + List findings = new ArrayList<>(); + for (Map server : mcpServers) { + Object name = server.get("name"); + Object transport = server.get("transport"); + if (name == null || transport == null || !KNOWN_TRANSPORTS.contains(transport.toString())) { + continue; // already reported by RuntimeBindingValidator + } + probes.add(probeOne(server, name.toString(), timeout, findings)); + } + return new ProbeReport(List.copyOf(probes), List.copyOf(findings)); + } + + private ServerProbe probeOne(Map server, String name, + java.time.Duration timeout, List findings) { + try (McpClient client = clientFactory.create(server)) { + List tools = client.listTools(timeout); + List toolNames = tools.stream().map(McpTool::name).toList(); + checkToolFilters(server, name, toolNames, findings); + return ServerProbe.connected(name, toolNames); + } catch (Exception exception) { + String message = String.valueOf(exception.getMessage()); + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_CONNECT_FAILED", + "MCP server '" + name + "' cannot be reached: " + + (message.length() > 300 ? message.substring(0, 300) + "…" : message))); + return ServerProbe.failed(name, message); + } + } + + private void checkToolFilters(Map server, String name, List toolNames, + List findings) { + if (!(server.get("toolFilters") instanceof List filters) || filters.isEmpty()) { + return; + } + Set discovered = Set.copyOf(toolNames); + List unknown = filters.stream() + .map(String::valueOf) + .filter(filter -> !discovered.contains(filter)) + .collect(Collectors.toList()); + if (!unknown.isEmpty()) { + findings.add(FindingDraft.warning(ValidationLayer.CONFIG, "MCP_TOOL_FILTER_UNKNOWN", + "runtime binding", "MCP server '" + name + "' toolFilters reference unknown " + + "tool(s): " + String.join(", ", unknown) + "; discovered tools: " + + (toolNames.isEmpty() ? "(none)" : String.join(", ", toolNames)))); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpTool.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpTool.java new file mode 100644 index 000000000..ad6f546eb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpTool.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * One tool discovered from an MCP server via {@code tools/list}. + */ +public record McpTool(String name, String description, JsonNode inputSchema) { + + @Override + public String toString() { + return name; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClient.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClient.java new file mode 100644 index 000000000..bb9ef8cbb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClient.java @@ -0,0 +1,236 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * MCP client for the stdio transport: the declared command is spawned as a + * subprocess and JSON-RPC messages are exchanged as newline-delimited JSON on + * its stdin/stdout. Only environment variables named in {@code envRefs} are + * passed through; the process is destroyed on close. A single stdout line is + * capped so a hostile server cannot exhaust memory with an unterminated line. + */ +public class StdioMcpClient implements McpClient { + + /** One JSON-RPC message may not exceed this many characters. */ + private static final int MAX_LINE_CHARS = 2 * 1024 * 1024; + private static final long TEARDOWN_TIMEOUT_MS = 5_000; + + private final String serverName; + private final Process process; + private final BufferedWriter stdin; + private final BufferedReader stdout; + private final ObjectMapper objectMapper; + private final List teardownCommand; + private int nextId = 1; + + public StdioMcpClient(String serverName, List command, Map environment, + ObjectMapper objectMapper) throws IOException { + this(serverName, command, environment, objectMapper, null, false); + } + + /** + * @param teardownCommand best-effort cleanup executed after the process is + * destroyed (e.g. {@code docker rm -f }); + * may be null + * @param inheritEnvironment when true the parent environment is kept — only + * legitimate for docker-wrapped commands, where the + * docker CLI needs it to reach the daemon and the + * container only receives its explicit {@code -e} vars + */ + public StdioMcpClient(String serverName, List command, Map environment, + ObjectMapper objectMapper, List teardownCommand, + boolean inheritEnvironment) throws IOException { + this.serverName = serverName; + this.objectMapper = objectMapper; + this.teardownCommand = teardownCommand; + ProcessBuilder processBuilder = new ProcessBuilder(command); + if (!inheritEnvironment) { + processBuilder.environment().clear(); + processBuilder.environment().putAll(environment); + } + processBuilder.redirectErrorStream(false); + this.process = processBuilder.start(); + this.stdin = new BufferedWriter( + new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + this.stdout = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + } + + @Override + public String serverName() { + return serverName; + } + + @Override + public List listTools(Duration timeout) throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("protocolVersion", HttpMcpClient.PROTOCOL_VERSION); + params.set("capabilities", objectMapper.createObjectNode()); + ObjectNode clientInfo = params.putObject("clientInfo"); + clientInfo.put("name", HttpMcpClient.CLIENT_NAME); + clientInfo.put("version", "1.0"); + request("initialize", params, timeout); + notification("notifications/initialized"); + + JsonNode tools = request("tools/list", objectMapper.createObjectNode(), timeout); + List discovered = new ArrayList<>(); + for (JsonNode tool : tools.path("tools")) { + discovered.add(new McpTool( + tool.path("name").asText(""), + tool.path("description").asText(""), + tool.path("inputSchema"))); + } + return discovered; + } + + @Override + public String callTool(String toolName, Map arguments, Duration timeout) + throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("name", toolName); + params.set("arguments", objectMapper.valueToTree(arguments == null ? Map.of() : arguments)); + JsonNode result = request("tools/call", params, timeout); + if (result.path("isError").asBoolean(false)) { + throw new IOException("MCP tool '" + toolName + "' returned an error result on server '" + + serverName + "'"); + } + StringBuilder content = new StringBuilder(); + for (JsonNode part : result.path("content")) { + if ("text".equals(part.path("type").asText())) { + if (content.length() > 0) { + content.append('\n'); + } + content.append(part.path("text").asText("")); + } + } + return content.toString(); + } + + @Override + public void close() { + process.destroy(); + try { + if (!process.waitFor(3, TimeUnit.SECONDS)) { + process.destroyForcibly(); + } + } catch (InterruptedException ignored) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + } + runTeardown(); + } + + /** + * Best-effort cleanup after the process is gone: a SIGKILLed + * {@code docker run} client can leave its container behind, so the + * docker-wrapped mode removes the container by name. + */ + private void runTeardown() { + if (teardownCommand == null) { + return; + } + try { + Process cleanup = new ProcessBuilder(teardownCommand).start(); + if (!cleanup.waitFor(TEARDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + cleanup.destroyForcibly(); + } + } catch (IOException | InterruptedException exception) { + // cleanup is best-effort; the container also exits via --rm + } + } + + private JsonNode request(String method, ObjectNode params, Duration timeout) throws Exception { + ObjectNode request = objectMapper.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", nextId++); + request.put("method", method); + request.set("params", params); + stdin.write(request.toString()); + stdin.write('\n'); + stdin.flush(); + return readResponse(request.path("id").asInt(), timeout); + } + + private void notification(String method) throws IOException { + ObjectNode notification = objectMapper.createObjectNode(); + notification.put("jsonrpc", "2.0"); + notification.put("method", method); + stdin.write(notification.toString()); + stdin.write('\n'); + stdin.flush(); + } + + /** Reads stdout lines until the response with the matching id arrives. */ + private JsonNode readResponse(int requestId, Duration timeout) throws IOException { + // readLine() blocks, so a silently hanging server needs a watchdog: kill the + // process after the timeout and readLine unblocks with end-of-stream. + Thread watchdog = Thread.ofVirtual().start(() -> { + try { + Thread.sleep(timeout.toMillis()); + process.destroyForcibly(); + } catch (InterruptedException ignored) { + // the response arrived in time + } + }); + try { + long deadline = System.nanoTime() + timeout.toNanos(); + while (System.nanoTime() < deadline) { + String line = readLineCapped(); + if (line == null) { + throw new IOException("MCP server '" + serverName + "' (stdio) closed its output"); + } + String trimmed = line.trim(); + if (!trimmed.startsWith("{")) { + continue; + } + JsonNode node = objectMapper.readTree(trimmed); + if (node.path("id").asInt(-1) != requestId) { + continue; // notification or log output from the server + } + if (node.has("error")) { + throw new IOException("MCP server '" + serverName + "' error: " + + node.path("error").path("message").asText()); + } + return node.path("result"); + } + throw new IOException("MCP server '" + serverName + "' (stdio) timed out after " + timeout); + } finally { + watchdog.interrupt(); + } + } + + /** + * Reads one line like {@link BufferedReader#readLine()} but refuses lines + * beyond {@link #MAX_LINE_CHARS}: a hostile stdio server could otherwise + * buffer an unterminated line of unbounded length in memory. + */ + private String readLineCapped() throws IOException { + StringBuilder line = new StringBuilder(); + int character; + while ((character = stdout.read()) != -1) { + if (character == '\n' || character == '\r') { + return line.toString(); + } + line.append((char) character); + if (line.length() > MAX_LINE_CHARS) { + process.destroyForcibly(); + throw new IOException("MCP server '" + serverName + + "' (stdio) sent a line longer than " + MAX_LINE_CHARS + " chars"); + } + } + return line.length() == 0 ? null : line.toString(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/ValidationRunMaintenanceTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/ValidationRunMaintenanceTask.java new file mode 100644 index 000000000..f7c687c8f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/ValidationRunMaintenanceTask.java @@ -0,0 +1,117 @@ +package com.iflytek.skillhub.task; + +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.service.authoring.ValidationRunOrchestrator; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Bounded recovery for validation runs: active runs older than the configured stale + * threshold are settled as TIMED_OUT (covering executor crashes and restarts), and + * orphaned workspace directories are removed best-effort. + */ +@Component +public class ValidationRunMaintenanceTask { + + private static final Logger log = LoggerFactory.getLogger(ValidationRunMaintenanceTask.class); + + private final ValidationRunService runService; + private final ValidationRunOrchestrator orchestrator; + private final AuthoringProperties properties; + + public ValidationRunMaintenanceTask(ValidationRunService runService, + ValidationRunOrchestrator orchestrator, + AuthoringProperties properties) { + this.runService = runService; + this.orchestrator = orchestrator; + this.properties = properties; + } + + @Scheduled(fixedDelayString = "${skillhub.authoring.maintenance-interval-ms:30000}") + public void sweep() { + settleStaleRuns(); + cleanupWorkspaces(); + } + + private void settleStaleRuns() { + Instant cutoff = Instant.now().minus(Duration.ofMinutes(properties.getStaleRunMinutes())); + List stale = runService.findActiveRunsCreatedBefore(cutoff); + for (ValidationRun run : stale) { + log.warn("Settling stale validation run {} (draft {}, status {}) as TIMED_OUT", + run.getId(), run.getDraftId(), run.getStatus()); + orchestrator.settle(run.getId(), ValidationRunStatus.TIMED_OUT, + run.getErrorCount(), run.getWarningCount(), + Map.of("reason", "stale run swept by maintenance task")); + } + } + + /** Removes workspace directories of runs that are no longer active. */ + private void cleanupWorkspaces() { + Path root = Path.of(properties.getWorkspaceRoot()); + if (!Files.isDirectory(root)) { + return; + } + try (var directories = Files.list(root)) { + directories.filter(Files::isDirectory) + .filter(directory -> directory.getFileName().toString().startsWith("run-")) + .forEach(this::cleanupIfOrphaned); + } catch (IOException exception) { + log.warn("Failed to list validation workspaces under {}: {}", root, exception.getMessage()); + } + } + + private void cleanupIfOrphaned(Path directory) { + String fileName = directory.getFileName().toString(); + long runId; + try { + runId = Long.parseLong(fileName.substring("run-".length())); + } catch (NumberFormatException exception) { + return; + } + try { + ValidationRun run = runService.getRun(runId); + if (run.isActive()) { + return; + } + try (var stream = Files.walk(directory)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // best-effort cleanup + } + }); + } + } catch (Exception exception) { + // unknown run id (workspace of a deleted draft) — remove the directory too + deleteQuietly(directory); + } + } + + private void deleteQuietly(Path directory) { + try (var stream = Files.walk(directory)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // best-effort cleanup + } + }); + } catch (IOException ignored) { + // best-effort cleanup + } + } +} diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index a4fc6bdf9..e72511414 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -53,6 +53,20 @@ skillhub: moderation: machine-review: ${SKILLHUB_PROFILE_MACHINE_REVIEW_ENABLED:true} human-review: ${SKILLHUB_PROFILE_HUMAN_REVIEW_ENABLED:true} + authoring: + local-script: + # local development runs scripts on the host; production defaults to docker + execution-mode: ${SKILLHUB_AUTHORING_LOCAL_SCRIPT_MODE:inline} + openai-compatible: + # local fake LLM endpoints live on loopback + allow-private-endpoints: ${SKILLHUB_AUTHORING_LLM_ALLOW_PRIVATE:true} + mcp: + # local fake MCP servers live on loopback; link-local stays blocked + allow-private-endpoints: ${SKILLHUB_AUTHORING_MCP_ALLOW_PRIVATE:true} + # stdio servers may be exercised in local development + stdio-enabled: ${SKILLHUB_AUTHORING_MCP_STDIO_ENABLED:true} + # empty by default: envRefs are rejected in dev unless explicitly opened + env-allowlist: ${SKILLHUB_AUTHORING_MCP_ENV_ALLOWLIST:} logging: level: diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 6dfbbd429..2e9ea20a3 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -234,6 +234,57 @@ skillhub: max-package-size: 104857600 # 100MB # allowed-file-extensions: uses SkillPackagePolicy.ALLOWED_EXTENSIONS by default # Override via SKILLHUB_PUBLISH_ALLOWED_FILE_EXTENSIONS env var if needed + authoring: + # Root directory for per-run isolated validation workspaces + workspace-root: ${SKILLHUB_AUTHORING_WORKSPACE_ROOT:${java.io.tmpdir}/skillhub-authoring} + executor-threads: ${SKILLHUB_AUTHORING_EXECUTOR_THREADS:4} + # Hard wall-clock cap for one whole validation run (15 minutes) + run-timeout-ms: ${SKILLHUB_AUTHORING_RUN_TIMEOUT_MS:900000} + # Active runs older than this are swept to TIMED_OUT (crash recovery) + stale-run-minutes: ${SKILLHUB_AUTHORING_STALE_RUN_MINUTES:30} + maintenance-interval-ms: ${SKILLHUB_AUTHORING_MAINTENANCE_INTERVAL_MS:30000} + local-script: + enabled: ${SKILLHUB_AUTHORING_LOCAL_SCRIPT_ENABLED:true} + # docker is the production default: scripts run in a locked-down container — + # no network, memory/cpu/pids caps, read-only rootfs, all capabilities dropped. + # inline runs subprocesses on the server host and is only allowed with a + # local/dev/test profile (the startup guard fails fast otherwise). + execution-mode: ${SKILLHUB_AUTHORING_LOCAL_SCRIPT_MODE:docker} + docker: + # must provide the interpreters your skills use (alpine ships sh only) + image: ${SKILLHUB_AUTHORING_DOCKER_IMAGE:alpine:3.20} + memory: ${SKILLHUB_AUTHORING_DOCKER_MEMORY:256m} + cpus: ${SKILLHUB_AUTHORING_DOCKER_CPUS:1.0} + pids-limit: ${SKILLHUB_AUTHORING_DOCKER_PIDS_LIMIT:128} + tmpfs-size: ${SKILLHUB_AUTHORING_DOCKER_TMPFS_SIZE:64m} + openai-compatible: + # Disabled by default: point it at an OpenAI-compatible endpoint to enable prompt tasks + enabled: ${SKILLHUB_AUTHORING_LLM_ENABLED:false} + api-key: ${SKILLHUB_AUTHORING_LLM_API_KEY:} + default-endpoint: ${SKILLHUB_AUTHORING_LLM_ENDPOINT:} + default-model: ${SKILLHUB_AUTHORING_LLM_MODEL:} + timeout-ms: ${SKILLHUB_AUTHORING_LLM_TIMEOUT_MS:120000} + # rounds of chat-completion (with MCP tool execution between rounds) per prompt task + max-tool-rounds: ${SKILLHUB_AUTHORING_LLM_MAX_TOOL_ROUNDS:4} + # SSRF guard: set true only for private-network LLM endpoints (e.g. internal vLLM); + # loopback/RFC1918 destinations are rejected by default, link-local always + allow-private-endpoints: ${SKILLHUB_AUTHORING_LLM_ALLOW_PRIVATE:false} + mcp: + # SSRF guard for http/sse MCP endpoints: private/loopback addresses are + # rejected by default; link-local (cloud metadata) is always rejected + allow-private-endpoints: ${SKILLHUB_AUTHORING_MCP_ALLOW_PRIVATE:false} + # stdio MCP servers spawn server-side processes — off unless explicitly enabled + stdio-enabled: ${SKILLHUB_AUTHORING_MCP_STDIO_ENABLED:false} + # server environment variables a binding may reference via envRefs + # (comma-separated, e.g. MCP_ECHO_TOKEN,WEATHER_API_KEY) + env-allowlist: ${SKILLHUB_AUTHORING_MCP_ENV_ALLOWLIST:} + docker: + # isolation profile for stdio MCP servers in docker execution mode + image: ${SKILLHUB_AUTHORING_MCP_DOCKER_IMAGE:alpine:3.20} + memory: ${SKILLHUB_AUTHORING_MCP_DOCKER_MEMORY:256m} + cpus: ${SKILLHUB_AUTHORING_MCP_DOCKER_CPUS:1.0} + pids-limit: ${SKILLHUB_AUTHORING_MCP_DOCKER_PIDS_LIMIT:128} + tmpfs-size: ${SKILLHUB_AUTHORING_MCP_DOCKER_TMPFS_SIZE:64m} profile: moderation: machine-review: ${SKILLHUB_PROFILE_MACHINE_REVIEW_ENABLED:true} # Enable machine review (e.g. sensitive word detection) diff --git a/server/skillhub-app/src/main/resources/db/migration/V68__authoring_platform.sql b/server/skillhub-app/src/main/resources/db/migration/V68__authoring_platform.sql new file mode 100644 index 000000000..d986818a9 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V68__authoring_platform.sql @@ -0,0 +1,130 @@ +-- Skill authoring platform: drafts, draft files, runtime bindings, and validation +-- runs with their persisted events and findings. Drafts feed validated content into +-- the existing publish pipeline, so these tables are authored standalone and only +-- reference namespace/skill_version loosely (no circular foreign keys). + +CREATE TABLE skill_draft ( + id BIGSERIAL PRIMARY KEY, + namespace_id BIGINT NOT NULL REFERENCES namespace(id), + owner_id VARCHAR(128) NOT NULL, + name VARCHAR(128) NOT NULL, + requirement TEXT, + revision INT NOT NULL DEFAULT 1, + content_digest VARCHAR(64) NOT NULL, + validated_revision INT, + validated_run_id BIGINT, + submitted_skill_id BIGINT, + submitted_version_id BIGINT, + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT chk_skill_draft_revision CHECK (revision > 0) +); + +-- Name uniqueness is enforced case-insensitively per owner. +CREATE UNIQUE INDEX uq_skill_draft_owner_name + ON skill_draft(owner_id, LOWER(name)); + +CREATE INDEX idx_skill_draft_owner_updated + ON skill_draft(owner_id, updated_at DESC); + +CREATE INDEX idx_skill_draft_namespace + ON skill_draft(namespace_id); + +-- File bodies live in object storage under content-addressed keys; this table tracks +-- identity, digest, and size only. +CREATE TABLE draft_file ( + id BIGSERIAL PRIMARY KEY, + draft_id BIGINT NOT NULL REFERENCES skill_draft(id) ON DELETE CASCADE, + file_path VARCHAR(512) NOT NULL, + sha256 VARCHAR(64) NOT NULL, + size BIGINT NOT NULL, + content_type VARCHAR(128), + storage_key VARCHAR(768) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_draft_file_path UNIQUE (draft_id, file_path), + CONSTRAINT chk_draft_file_size CHECK (size >= 0) +); + +CREATE INDEX idx_draft_file_draft + ON draft_file(draft_id); + +CREATE INDEX idx_draft_file_storage_key + ON draft_file(storage_key); + +-- One runtime binding per draft; secrets are referenced by name and resolved from +-- server-side configuration, never stored inline. +CREATE TABLE runtime_binding ( + id BIGSERIAL PRIMARY KEY, + draft_id BIGINT NOT NULL REFERENCES skill_draft(id) ON DELETE CASCADE, + agent_type VARCHAR(32) NOT NULL, + config JSONB, + tool_allowlist JSONB, + mcp_servers JSONB, + updated_by VARCHAR(128), + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_runtime_binding_draft UNIQUE (draft_id) +); + +CREATE TABLE validation_run ( + id BIGSERIAL PRIMARY KEY, + draft_id BIGINT NOT NULL REFERENCES skill_draft(id) ON DELETE CASCADE, + draft_revision INT NOT NULL, + status VARCHAR(20) NOT NULL, + cancel_requested BOOLEAN NOT NULL DEFAULT FALSE, + error_count INT NOT NULL DEFAULT 0, + warning_count INT NOT NULL DEFAULT 0, + summary JSONB, + triggered_by VARCHAR(128) NOT NULL, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT chk_validation_run_error_count CHECK (error_count >= 0), + CONSTRAINT chk_validation_run_warning_count CHECK (warning_count >= 0) +); + +CREATE INDEX idx_validation_run_draft_created + ON validation_run(draft_id, created_at DESC); + +-- Partial index keeps the active-run uniqueness cheap for the maintenance sweep. +CREATE INDEX idx_validation_run_status + ON validation_run(status) + WHERE status IN ('QUEUED', 'PREPARING', 'RUNNING'); + +CREATE INDEX idx_validation_run_stale_sweep + ON validation_run(status, created_at); + +CREATE TABLE validation_event ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL REFERENCES validation_run(id) ON DELETE CASCADE, + seq INT NOT NULL, + event_type VARCHAR(32) NOT NULL, + phase VARCHAR(32), + payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_validation_event_seq UNIQUE (run_id, seq) +); + +CREATE TABLE validation_finding ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL REFERENCES validation_run(id) ON DELETE CASCADE, + layer VARCHAR(16) NOT NULL, + rule_code VARCHAR(64) NOT NULL, + severity VARCHAR(16) NOT NULL, + file_path VARCHAR(512), + location VARCHAR(128), + message TEXT NOT NULL, + suggestion JSONB, + status VARCHAR(16) NOT NULL DEFAULT 'OPEN', + applied_revision INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_validation_finding_status + CHECK (status IN ('OPEN', 'APPLIED', 'DISMISSED')) +); + +CREATE INDEX idx_validation_finding_run_status + ON validation_finding(run_id, status); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 337efb134..f87f1f00e 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -273,3 +273,44 @@ promotion.revocation.not_found=Revocation request {0} was not found promotion.revocation.not_pending=Revocation request {0} is no longer pending promotion.revocation.required=Use the reviewed revocation workflow to remove a promoted skill promotion.revocation.page_invalid=Page must be nonnegative and size must be between 1 and 100 + +# Skill authoring and validation platform +error.authoring.draft.notFound=Draft not found: {0} +error.authoring.draft.forbidden=You do not own this draft: {0} +error.authoring.draft.name.required=Draft name is required +error.authoring.draft.name.tooLong=Draft name must be at most {0} characters +error.authoring.draft.name.duplicate=You already have a draft named ''{0}'' +error.authoring.draft.requirement.tooLong=Requirement text must be at most {0} characters +error.authoring.draft.notMember=You are not a member of namespace ''{0}'' +error.authoring.draft.revision.conflict=Draft revision mismatch: expected {0} but current is {1} +error.authoring.namespace.notFound=Namespace not found: {0} +error.authoring.namespace.frozen=Namespace ''{0}'' is frozen and cannot receive new drafts +error.authoring.namespace.archived=Namespace ''{0}'' is archived and cannot receive new drafts +error.authoring.file.notFound=Draft file not found: {0} +error.authoring.file.path.invalid=Invalid draft file path: {0} +error.authoring.file.content.required=File content is required +error.authoring.file.tooLarge=File {0} exceeds the {1} byte limit +error.authoring.file.contentMismatch=File {0} content does not match its extension: {1} +error.authoring.file.countExceeded=Draft exceeds the maximum of {0} files +error.authoring.file.extension.disallowed=File extension is not allowed: {0} +error.authoring.package.tooLarge=Draft total size exceeds the {0} byte limit +error.authoring.patch.empty=No patches to apply +error.authoring.patch.fileExists=Patch target already exists: {0} +error.authoring.patch.fileMissing=Patch target not found: {0} +error.authoring.patch.stale=File ''{0}'' changed since the suggestion was generated +error.authoring.patch.noChange=The patch did not change the draft content +error.authoring.binding.notFound=No runtime binding configured for draft {0} +error.authoring.binding.agentType.unknown=Unknown agent runtime type: {0} +error.authoring.binding.invalid=Invalid runtime binding: {0} +error.authoring.binding.unserializable=Runtime binding could not be serialized +error.authoring.run.notFound=Validation run not found: {0} +error.authoring.run.activeExists=Draft {0} already has an active validation run +error.authoring.run.notQueued=Validation run {0} is not queued +error.authoring.run.cancelled=Validation run {0} was cancelled +error.authoring.run.terminal=Validation run {0} is already finished +error.authoring.finding.notFound=Validation finding not found: {0} +error.authoring.finding.noSuggestion=Finding {0} has no applicable fix suggestion +error.authoring.finding.applied=The fix for finding {0} was already applied +error.authoring.finding.suggestionUnreadable=The fix suggestion of finding {0} could not be read +error.authoring.submit.notValidated=Revision {0} has no passing validation run; run validation first +error.authoring.submit.runActive=Draft {0} has an active validation run diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 6baf1a557..489ffcb4f 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -214,3 +214,44 @@ error.suite.bundle.operation.cancel.notAllowed=Эту операцию паке error.suite.bundle.operation.retry.notAllowed=Повторить можно только заблокированную операцию Skill Suite, допускающую повтор error.suite.bundle.member.stateChanged=Состояние связанного Skill или версии изменилось; создайте новый предварительный просмотр пакета Skill Suite error.suite.bundle.actor.inactive=Пользователь операции Skill Suite Bundle больше не активен + +# Платформа создания и проверки навыков +error.authoring.draft.notFound=Черновик не найден: {0} +error.authoring.draft.forbidden=Вы не владеете этим черновиком: {0} +error.authoring.draft.name.required=Укажите название черновика +error.authoring.draft.name.tooLong=Название черновика не должно превышать {0} символов +error.authoring.draft.name.duplicate=У вас уже есть черновик с названием ''{0}'' +error.authoring.draft.requirement.tooLong=Описание требований не должно превышать {0} символов +error.authoring.draft.notMember=Вы не участник пространства имён ''{0}'' +error.authoring.draft.revision.conflict=Несовпадение ревизии черновика: ожидалась {0}, текущая {1} +error.authoring.namespace.notFound=Пространство имён не найдено: {0} +error.authoring.namespace.frozen=Пространство имён ''{0}'' заморожено, новые черновики недоступны +error.authoring.namespace.archived=Пространство имён ''{0}'' архивировано, новые черновики недоступны +error.authoring.file.notFound=Файл черновика не найден: {0} +error.authoring.file.path.invalid=Недопустимый путь к файлу черновика: {0} +error.authoring.file.content.required=Требуется содержимое файла +error.authoring.file.tooLarge=Файл {0} превышает ограничение в {1} байт +error.authoring.file.contentMismatch=Содержимое файла {0} не соответствует его расширению: {1} +error.authoring.file.countExceeded=Черновик превышает максимум в {0} файлов +error.authoring.file.extension.disallowed=Расширение файла не разрешено: {0} +error.authoring.package.tooLarge=Общий размер черновика превышает ограничение в {0} байт +error.authoring.patch.empty=Нет патчей для применения +error.authoring.patch.fileExists=Целевой файл патча уже существует: {0} +error.authoring.patch.fileMissing=Целевой файл патча не найден: {0} +error.authoring.patch.stale=Файл ''{0}'' изменился после формирования рекомендации +error.authoring.patch.noChange=Патч не изменил содержимое черновика +error.authoring.binding.notFound=Для черновика {0} не настроена привязка среды выполнения +error.authoring.binding.agentType.unknown=Неизвестный тип среды выполнения агента: {0} +error.authoring.binding.invalid=Недопустимая привязка среды выполнения: {0} +error.authoring.binding.unserializable=Привязка среды выполнения не может быть сериализована +error.authoring.run.notFound=Задача проверки не найдена: {0} +error.authoring.run.activeExists=У черновика {0} уже есть активная задача проверки +error.authoring.run.notQueued=Задача проверки {0} не в очереди +error.authoring.run.cancelled=Задача проверки {0} была отменена +error.authoring.run.terminal=Задача проверки {0} уже завершена +error.authoring.finding.notFound=Результат проверки не найден: {0} +error.authoring.finding.noSuggestion=Для результата {0} нет применимой рекомендации +error.authoring.finding.applied=Исправление результата {0} уже применено +error.authoring.finding.suggestionUnreadable=Не удалось прочитать рекомендацию для результата {0} +error.authoring.submit.notValidated=У ревизии {0} нет пройденной задачи проверки; сначала выполните проверку +error.authoring.submit.runActive=У черновика {0} есть активная задача проверки diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 3a6f22a89..0c2dd81e0 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -273,3 +273,44 @@ promotion.revocation.not_found=撤销申请 {0} 不存在 promotion.revocation.not_pending=撤销申请 {0} 已不在待审核状态 promotion.revocation.required=已提升的技能须通过审核撤销流程删除 promotion.revocation.page_invalid=页码不能为负数,每页数量须在 1 到 100 之间 + +# 技能创作与验证平台 +error.authoring.draft.notFound=草稿不存在:{0} +error.authoring.draft.forbidden=你不是该草稿的所有者:{0} +error.authoring.draft.name.required=草稿名称不能为空 +error.authoring.draft.name.tooLong=草稿名称不能超过 {0} 个字符 +error.authoring.draft.name.duplicate=你已经有一个名为“{0}”的草稿 +error.authoring.draft.requirement.tooLong=需求描述不能超过 {0} 个字符 +error.authoring.draft.notMember=你不是命名空间“{0}”的成员 +error.authoring.draft.revision.conflict=草稿版本不匹配:期望 {0},当前为 {1} +error.authoring.namespace.notFound=命名空间不存在:{0} +error.authoring.namespace.frozen=命名空间“{0}”已被冻结,无法创建新草稿 +error.authoring.namespace.archived=命名空间“{0}”已被归档,无法创建新草稿 +error.authoring.file.notFound=草稿文件不存在:{0} +error.authoring.file.path.invalid=非法的草稿文件路径:{0} +error.authoring.file.content.required=文件内容不能为空 +error.authoring.file.tooLarge=文件 {0} 超过了 {1} 字节的上限 +error.authoring.file.contentMismatch=文件 {0} 的内容与扩展名不匹配:{1} +error.authoring.file.countExceeded=草稿文件数超过了上限 {0} +error.authoring.file.extension.disallowed=不允许的文件扩展名:{0} +error.authoring.package.tooLarge=草稿总大小超过了 {0} 字节的上限 +error.authoring.patch.empty=没有可应用的补丁 +error.authoring.patch.fileExists=补丁目标文件已存在:{0} +error.authoring.patch.fileMissing=补丁目标文件不存在:{0} +error.authoring.patch.stale=文件“{0}”在建议生成后发生了变化 +error.authoring.patch.noChange=补丁没有改变草稿内容 +error.authoring.binding.notFound=草稿 {0} 尚未配置运行时绑定 +error.authoring.binding.agentType.unknown=未知的 Agent 运行时类型:{0} +error.authoring.binding.invalid=运行时绑定不合法:{0} +error.authoring.binding.unserializable=运行时绑定无法序列化 +error.authoring.run.notFound=验证任务不存在:{0} +error.authoring.run.activeExists=草稿 {0} 已有一个进行中的验证任务 +error.authoring.run.notQueued=验证任务 {0} 不处于排队状态 +error.authoring.run.cancelled=验证任务 {0} 已被取消 +error.authoring.run.terminal=验证任务 {0} 已经结束 +error.authoring.finding.notFound=验证问题项不存在:{0} +error.authoring.finding.noSuggestion=问题项 {0} 没有可应用的修复建议 +error.authoring.finding.applied=问题项 {0} 的修复已被应用 +error.authoring.finding.suggestionUnreadable=问题项 {0} 的修复建议无法解析 +error.authoring.submit.notValidated=版本 {0} 没有通过的验证任务,请先执行验证 +error.authoring.submit.runActive=草稿 {0} 有正在进行的验证任务 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringFlowIntegrationTest.java new file mode 100644 index 000000000..6464abb16 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringFlowIntegrationTest.java @@ -0,0 +1,397 @@ +package com.iflytek.skillhub.authoring; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.service.DraftSubmitService; +import com.iflytek.skillhub.domain.authoring.service.FindingFixService; +import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingService; +import com.iflytek.skillhub.domain.authoring.service.SkillDraftService; +import com.iflytek.skillhub.domain.authoring.service.ValidationRunService; +import com.iflytek.skillhub.domain.authoring.validation.FindingSeverity; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.service.authoring.ValidationRunOrchestrator; +import com.iflytek.skillhub.storage.ObjectMetadata; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * End-to-end authoring flow against the real service stack (Testcontainers + * PostgreSQL, in-memory object storage, mocked publish pipeline): create draft → + * edit files → bind runtime → validate (structure + config + behavior) → fix + * findings → re-validate → submit. Runs on real PostgreSQL because the JSONB + * columns must round-trip exactly as they do in production. + */ +@SpringBootTest +@ActiveProfiles("test") +@Import({TestRedisConfig.class, AuthoringFlowIntegrationTest.InMemoryStorageConfig.class}) +@Testcontainers +class AuthoringFlowIntegrationTest { + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + } + + private static final String USER = "author-user-1"; + private static final String NS_SLUG = "authoring-test-ns"; + + @Autowired private SkillDraftService draftService; + @Autowired private RuntimeBindingService bindingService; + @Autowired private ValidationRunService runService; + @Autowired private DraftSubmitService submitService; + @Autowired private FindingFixService fixService; + @Autowired private ValidationRunOrchestrator orchestrator; + @Autowired private NamespaceRepository namespaceRepository; + @Autowired private NamespaceMemberRepository namespaceMemberRepository; + @Autowired private UserAccountRepository userAccountRepository; + + @MockBean private SkillPublishService publishService; + + @TestConfiguration + static class InMemoryStorageConfig { + + @Bean + @Primary + ObjectStorageService inMemoryObjectStorage() { + return new InMemoryObjectStorage(); + } + } + + static final class InMemoryObjectStorage implements ObjectStorageService { + private final Map objects = new ConcurrentHashMap<>(); + + @Override + public void putObject(String key, InputStream data, long size, String contentType) { + try { + objects.put(key, data.readAllBytes()); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + @Override + public InputStream getObject(String key) { + byte[] content = objects.get(key); + if (content == null) { + throw new IllegalArgumentException("missing object: " + key); + } + return new java.io.ByteArrayInputStream(content); + } + + @Override + public void deleteObject(String key) { + objects.remove(key); + } + + @Override + public void deleteObjects(List keys) { + keys.forEach(objects::remove); + } + + @Override + public boolean exists(String key) { + return objects.containsKey(key); + } + + @Override + public ObjectMetadata getMetadata(String key) { + byte[] content = objects.get(key); + if (content == null) { + throw new IllegalArgumentException("missing object: " + key); + } + return new ObjectMetadata(content.length, "application/octet-stream", java.time.Instant.now()); + } + + @Override + public String generatePresignedUrl(String key, Duration expiry, String downloadFilename) { + return "http://localhost/" + key; + } + } + + @BeforeEach + void setUpNamespace() { + if (userAccountRepository.findById(USER).isEmpty()) { + userAccountRepository.save(new UserAccount(USER, "Author User", "author@example.com", null)); + } + if (namespaceRepository.findBySlug(NS_SLUG).isEmpty()) { + Namespace namespace = new Namespace(NS_SLUG, "Authoring Test", USER); + namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.ACTIVE); + Namespace saved = namespaceRepository.save(namespace); + if (namespaceMemberRepository.findByNamespaceIdAndUserId(saved.getId(), USER).isEmpty()) { + namespaceMemberRepository.save(new NamespaceMember(saved.getId(), USER, NamespaceRole.MEMBER)); + } + } + } + + @Test + void validatedDraftRunsAllLayersAndSubmits() { + // ---- create with scaffold + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "demo-flow", "summarizes documents", null); + assertThat(draft.getRevision()).isEqualTo(1); + assertThat(draftService.listFiles(draft.getId())) + .extracting(file -> file.getFilePath()) + .containsExactly("SKILL.md"); + // the scaffold bytes must be readable straight away, before any manual + // save — creation persists content, not just the metadata row + assertThat(draftService.readFile(draft.getId(), USER, "SKILL.md", null).asText()) + .contains("name: demo-flow") + .contains("description: summarizes documents"); + + // ---- edit files: overwrite SKILL.md, add script + validation.yaml + draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMd(), "text/markdown", null, null); + draftService.saveFile(draft.getId(), USER, "scripts/check.sh", + "echo 'smoke OK'\n".getBytes(StandardCharsets.UTF_8), null, null, null); + draftService.saveFile(draft.getId(), USER, "validation.yaml", + validationYaml().getBytes(StandardCharsets.UTF_8), null, null, null); + assertThat(draftService.getDraft(draft.getId()).getRevision()).isEqualTo(4); + + // ---- bind runtime: local script execution + bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, null, null); + + // ---- validate: all three layers + ValidationRun run = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(run.getId()); + ValidationRun finished = awaitTerminal(run.getId()); + + assertThat(finished.getStatus()).isEqualTo(ValidationRunStatus.SUCCEEDED); + assertThat(finished.getErrorCount()).isZero(); + assertThat(runService.listFindings(run.getId())).isEmpty(); + assertThat(runService.listEvents(run.getId(), null)) + .extracting(event -> event.getEventType().name()) + .contains("RUN_STARTED", "PHASE_STARTED", "TOOL_CALL", "LOG", "RUN_FINISHED"); + + // ---- draft is validated for this revision + SkillDraft validated = draftService.getDraft(draft.getId()); + assertThat(validated.isCurrentRevisionValidated()).isTrue(); + assertThat(validated.getValidatedRevision()).isEqualTo(validated.getRevision()); + + // ---- submit into the (mocked) publish pipeline + SkillVersion publishedVersion = new SkillVersion(99L, "1.0.0", USER); + when(publishService.publishFromEntries(eq(NS_SLUG), any(), eq(USER), + eq(SkillVisibility.PRIVATE), any(), anyBoolean())) + .thenReturn(new SkillPublishService.PublishResult(99L, "demo-flow", publishedVersion)); + + DraftSubmitService.SubmitOutcome outcome = submitService.submit( + draft.getId(), USER, SkillVisibility.PRIVATE, null); + + assertThat(outcome.skillId()).isEqualTo(99L); + assertThat(outcome.version()).isEqualTo("1.0.0"); + assertThat(draftService.getDraft(draft.getId()).getSubmittedSkillId()).isEqualTo(99L); + } + + @Test + void brokenFrontmatterProducesFixableFindingAndRevalidationLoop() { + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "broken-flow", null, null); + // SKILL.md without the required description field + draftService.saveFile(draft.getId(), USER, "SKILL.md", + "---\nname: broken-flow\n---\n\n# Broken\n".getBytes(StandardCharsets.UTF_8), + "text/markdown", null, null); + + ValidationRun run = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(run.getId()); + awaitTerminal(run.getId()); + + List findings = runService.listFindings(run.getId()); + assertThat(findings).isNotEmpty(); + Optional missingField = findings.stream() + .filter(finding -> "FRONTMATTER_FIELD_MISSING".equals(finding.getRuleCode())) + .findFirst(); + assertThat(missingField).isPresent(); + assertThat(missingField.get().getSeverity()).isEqualTo(FindingSeverity.ERROR); + assertThat(missingField.get().hasSuggestion()).isTrue(); + assertThat(runService.getRun(run.getId()).getStatus()).isEqualTo(ValidationRunStatus.FAILED); + + // ---- apply the suggested fix + int revisionBefore = draftService.getDraft(draft.getId()).getRevision(); + FindingFixService.FixOutcome outcome = fixService.applyFix( + missingField.get().getId(), USER, null); + assertThat(outcome.finding().getStatus().name()).isEqualTo("APPLIED"); + assertThat(outcome.draft().getRevision()).isGreaterThan(revisionBefore); + + // ---- validation verdict is invalidated by the new revision + SkillDraft fixed = draftService.getDraft(draft.getId()); + assertThat(fixed.isCurrentRevisionValidated()).isFalse(); + + // ---- re-validate: now the description field exists and the run passes + ValidationRun second = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(second.getId()); + ValidationRun finishedSecond = awaitTerminal(second.getId()); + + assertThat(finishedSecond.getStatus()).isEqualTo(ValidationRunStatus.SUCCEEDED); + assertThat(draftService.getDraft(draft.getId()).isCurrentRevisionValidated()).isTrue(); + } + + @Test + void deadMcpServerFailsTheRunWithoutValidationYaml() { + // no validation.yaml on purpose: the config layer must still check the + // runtime binding — a declared MCP server that cannot be reached is a + // config error even when the behavior layer has no tasks to run + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "mcp-probe-flow", + "declares a dead MCP server", null); + draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMd(), "text/markdown", null, null); + + bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "dead-server", "transport", "http", + "endpoint", "http://127.0.0.1:9/mcp")), null); + + ValidationRun run = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(run.getId()); + ValidationRun finished = awaitTerminal(run.getId()); + + assertThat(finished.getStatus()).isEqualTo(ValidationRunStatus.FAILED); + assertThat(runService.listFindings(run.getId())) + .extracting(ValidationFinding::getRuleCode) + .contains("SPEC_MISSING", "MCP_CONNECT_FAILED"); + } + + @Test + void securityPolicyRejectsUnsafeBindingsAtSaveTime() { + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "security-flow", + "exercises the binding security policy", null); + draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMd(), "text/markdown", null, null); + + // cloud metadata endpoints are always blocked — even under the test + // profile where loopback/RFC1918 endpoints are deliberately allowed + assertBindingRejected(() -> bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "metadata", "transport", "http", + "endpoint", "http://169.254.169.254/latest/meta-data/")), null), + "link-local"); + + // envRefs outside the configured allowlist are rejected: bindings must + // not be able to read arbitrary server environment variables + assertBindingRejected(() -> bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "env-hog", "transport", "http", + "endpoint", "http://127.0.0.1:9/mcp", + "envRefs", List.of("HOME"))), null), + "env-allowlist"); + + // the LLM surface is guarded too — those requests carry the server API key + assertBindingRejected(() -> bindingService.saveBinding(draft.getId(), USER, "openai-compatible", + Map.of("endpoint", "http://169.254.169.254/v1", "model", "test-model"), null, null, null), + "link-local"); + + // the allowlisted env ref saves fine (loopback is allowed under the test profile) + bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "env-ok", "transport", "http", + "endpoint", "http://127.0.0.1:9/mcp", + "envRefs", List.of("TEST_ALLOWED_MCP_VAR"))), null); + } + + // ---------------------------------------------------------------- helpers + + /** Save-time rejection: a 400 whose first message arg explains the reason. */ + private static void assertBindingRejected(Runnable save, String reasonFragment) { + try { + save.run(); + fail("binding save should have been rejected: " + reasonFragment); + } catch (com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException exception) { + assertThat(String.valueOf(exception.messageArgs()[0])).contains(reasonFragment); + } + } + + private ValidationRun awaitTerminal(Long runId) { + long deadline = System.currentTimeMillis() + 60_000; + while (System.currentTimeMillis() < deadline) { + if (runService.getRun(runId).getStatus().isTerminal()) { + return runService.getRun(runId); + } + try { + Thread.sleep(200); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + fail("interrupted while waiting for the validation run to finish"); + } + } + fail("validation run " + runId + " did not reach a terminal state in time"); + return null; + } + + private static byte[] validSkillMd() { + return """ + --- + name: demo-flow + description: summarizes documents for the demo + --- + + # Demo flow + + ## Overview + + summarizes documents for the demo + """.getBytes(StandardCharsets.UTF_8); + } + + private static String validationYaml() { + return """ + version: 1 + tasks: + - name: smoke + description: script runs and prints OK + type: script + script: scripts/check.sh + args: [] + timeoutMs: 10000 + assertions: + - type: exit_code + equals: 0 + - type: stdout_contains + value: smoke OK + """; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AuthoringStartupGuardTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AuthoringStartupGuardTest.java new file mode 100644 index 000000000..09dfd282c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AuthoringStartupGuardTest.java @@ -0,0 +1,70 @@ +package com.iflytek.skillhub.config; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The startup guard makes host-side script execution impossible to configure + * by accident in production: inline mode outside local/dev/test fails fast. + */ +class AuthoringStartupGuardTest { + + private final AuthoringProperties properties = new AuthoringProperties(); + + private AuthoringStartupGuard guard(MockEnvironment environment) { + return new AuthoringStartupGuard(properties, environment); + } + + @Test + void inlineModeWithoutDevProfileFailsStartup() { + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.INLINE); + assertThatThrownBy(() -> guard(new MockEnvironment()).run(null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("execution-mode=inline") + .hasMessageContaining("docker"); + } + + @Test + void inlineModeWithProdLikeProfileStillFails() { + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.INLINE); + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles("redis-sentinel"); + assertThatThrownBy(() -> guard(environment).run(null)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void inlineModeIsAllowedInLocalDevAndTestProfiles() { + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.INLINE); + for (String profile : new String[]{"local", "dev", "test"}) { + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles(profile); + assertThatCode(() -> guard(environment).run(null)) + .as("profile " + profile) + .doesNotThrowAnyException(); + } + } + + @Test + void dockerModePassesEverywhere() { + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.DOCKER); + assertThatCode(() -> guard(new MockEnvironment()).run(null)) + .doesNotThrowAnyException(); + } + + @Test + void disabledLocalScriptPassesEverywhere() { + properties.getLocalScript().setEnabled(false); + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.INLINE); + assertThatCode(() -> guard(new MockEnvironment()).run(null)) + .doesNotThrowAnyException(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicyTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicyTest.java new file mode 100644 index 000000000..089a792ce --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicyTest.java @@ -0,0 +1,151 @@ +package com.iflytek.skillhub.config; + +import com.iflytek.skillhub.domain.authoring.service.AuthoringSecurityPolicy; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the SSRF classification of user-supplied endpoints: link-local (cloud + * metadata) is always blocked, private/loopback ranges follow the per-surface + * flags, and public addresses pass. + */ +class ConfiguredAuthoringSecurityPolicyTest { + + private final AuthoringProperties properties = new AuthoringProperties(); + private final ConfiguredAuthoringSecurityPolicy policy = + new ConfiguredAuthoringSecurityPolicy(properties); + + @Test + void publicHttpAndHttpsEndpointsAreAllowed() { + // literal public IPs keep the test hermetic: hostname resolution varies + // with the local network (VPNs and DNS64 may answer with private ranges) + assertThat(policy.endpointRejection("https://1.1.1.1/v1", + AuthoringSecurityPolicy.EndpointUse.LLM_API)).isNull(); + assertThat(policy.endpointRejection("http://8.8.8.8/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + } + + @Test + void nonHttpSchemesAndHostlessUrlsAreRejected() { + assertThat(policy.endpointRejection("file:///etc/passwd", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("http(s)"); + assertThat(policy.endpointRejection("ftp://example.com/x", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("http(s)"); + assertThat(policy.endpointRejection("http://", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http:///no-host-here", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("no host"); + assertThat(policy.endpointRejection("not a url at all", + AuthoringSecurityPolicy.EndpointUse.LLM_API)).isNotNull(); + } + + @Test + void cloudMetadataAndLinkLocalAreAlwaysBlockedEvenWhenPrivateIsAllowed() { + properties.getMcp().setAllowPrivateEndpoints(true); + properties.getOpenAiCompatible().setAllowPrivateEndpoints(true); + assertThat(policy.endpointRejection("http://169.254.169.254/latest/meta-data/", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("link-local"); + assertThat(policy.endpointRejection("http://169.254.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("link-local"); + assertThat(policy.endpointRejection("http://[fe80::1]/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + // the LLM surface carries the server API key — same absolute block + assertThat(policy.endpointRejection("http://169.254.169.254/v1", + AuthoringSecurityPolicy.EndpointUse.LLM_API)).contains("link-local"); + } + + @Test + void unspecifiedAndMulticastAreAlwaysBlocked() { + assertThat(policy.endpointRejection("http://0.0.0.0/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http://224.0.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + } + + @Test + void loopbackIsBlockedByDefaultAndAllowedWithTheFlag() { + assertThat(policy.endpointRejection("http://127.0.0.1:8080/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("private/loopback"); + assertThat(policy.endpointRejection("http://localhost:8080/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http://[::1]/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + + properties.getMcp().setAllowPrivateEndpoints(true); + assertThat(policy.endpointRejection("http://127.0.0.1:8080/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + // the LLM surface has its own flag and stays blocked + assertThat(policy.endpointRejection("http://127.0.0.1:8080/v1", + AuthoringSecurityPolicy.EndpointUse.LLM_API)).isNotNull(); + properties.getOpenAiCompatible().setAllowPrivateEndpoints(true); + assertThat(policy.endpointRejection("http://127.0.0.1:8080/v1", + AuthoringSecurityPolicy.EndpointUse.LLM_API)).isNull(); + } + + @Test + void privateV4V6AndCgnatRangesAreBlockedByDefault() { + assertThat(policy.endpointRejection("http://10.1.2.3/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http://172.16.0.9/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http://192.168.1.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + // 172.32.x.x is public (just outside the RFC1918 block) + assertThat(policy.endpointRejection("http://172.32.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + assertThat(policy.endpointRejection("http://100.64.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + // 100.128.x.x is public (just outside CGNAT) + assertThat(policy.endpointRejection("http://100.128.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + assertThat(policy.endpointRejection("http://[fd12::1]/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + } + + @Test + void unresolvableOrSyntheticHostsNeverPassSilently() throws Exception { + // Resolution is stubbed through the test seam: whether a bogus host + // fails to resolve (normal network) or a fake-IP VPN answers it with + // a synthetic range, the endpoint must not pass silently. + ConfiguredAuthoringSecurityPolicy unresolved = new ConfiguredAuthoringSecurityPolicy( + properties, host -> { throw new java.net.UnknownHostException("nx"); }); + assertThat(unresolved.endpointRejection("http://this-host-does-not-exist.invalid/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)) + .contains("cannot resolve host"); + + // fake-IP VPN resolvers answer everything inside 198.18.0.0/15 + java.net.InetAddress fakeVpnAnswer = java.net.InetAddress.getByAddress( + new byte[] {(byte) 198, 18, 1, (byte) 200}); + ConfiguredAuthoringSecurityPolicy fakeDns = new ConfiguredAuthoringSecurityPolicy( + properties, host -> new java.net.InetAddress[] {fakeVpnAnswer}); + assertThat(fakeDns.endpointRejection("http://anything.example/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + } + + @Test + void rfc2544BenchmarkRangeFollowsThePrivateFlag() { + assertThat(policy.endpointRejection("http://198.18.1.200/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http://198.19.255.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + // 198.20.x.x is public (just outside the /15) + assertThat(policy.endpointRejection("http://198.20.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + properties.getMcp().setAllowPrivateEndpoints(true); + assertThat(policy.endpointRejection("http://198.18.1.200/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + } + + @Test + void stdioAndEnvRefsFollowConfiguration() { + assertThat(policy.stdioTransportAllowed()).isFalse(); + properties.getMcp().setStdioEnabled(true); + assertThat(policy.stdioTransportAllowed()).isTrue(); + + assertThat(policy.envRefAllowed("ANY_VAR")).isFalse(); + properties.getMcp().setEnvAllowlist(java.util.List.of("WEATHER_API_KEY")); + assertThat(policy.envRefAllowed("WEATHER_API_KEY")).isTrue(); + assertThat(policy.envRefAllowed("PATH")).isFalse(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/TestingAuthoringSecurityPolicy.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/TestingAuthoringSecurityPolicy.java new file mode 100644 index 000000000..506680c45 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/TestingAuthoringSecurityPolicy.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.service.authoring; + +import com.iflytek.skillhub.domain.authoring.service.AuthoringSecurityPolicy; + +/** + * Ready-made policies for unit tests that build components by hand instead of + * through the Spring context. + */ +public final class TestingAuthoringSecurityPolicy { + + private TestingAuthoringSecurityPolicy() { + } + + /** Allows every endpoint, stdio, and env ref — for tests focused elsewhere. */ + public static AuthoringSecurityPolicy permissive() { + return new AuthoringSecurityPolicy() { + @Override + public String endpointRejection(String url, EndpointUse use) { + return null; + } + + @Override + public boolean stdioTransportAllowed() { + return true; + } + + @Override + public boolean envRefAllowed(String name) { + return true; + } + }; + } + + /** Production posture: rejects every endpoint, stdio, and env ref. */ + public static AuthoringSecurityPolicy denyAll() { + return new AuthoringSecurityPolicy() { + @Override + public String endpointRejection(String url, EndpointUse use) { + return "blocked by test policy"; + } + + @Override + public boolean stdioTransportAllowed() { + return false; + } + + @Override + public boolean envRefAllowed(String name) { + return false; + } + }; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilderTest.java new file mode 100644 index 000000000..d99ed4c1c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilderTest.java @@ -0,0 +1,85 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.config.AuthoringProperties; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies the docker command line: every isolation flag must be present, the + * workspace mounted exactly once, and the script referenced by its + * container-relative path. + */ +class DockerScriptCommandBuilderTest { + + private final AuthoringProperties properties = new AuthoringProperties(); + private final DockerScriptCommandBuilder builder = new DockerScriptCommandBuilder(properties); + + private final Path workspace = Path.of("/tmp/skillhub-authoring/run-42"); + private final Path script = workspace.resolve("scripts/greet.sh"); + + private ScriptCommandBuilder.ScriptExecutionPlan plan() { + return builder.plan(workspace, "sh", script, List.of("arg1", "arg two"), + Map.of("PATH", "/usr/bin:/bin", "HOME", workspace.toString(), "LANG", "C.UTF-8"), + "42-greet"); + } + + @Test + void commandEnforcesIsolationProfile() { + List command = plan().command(); + assertThat(command).containsSubsequence( + List.of("docker", "run", "--rm", "--name", "skillhub-validation-42-greet")); + assertThat(command).containsSubsequence(List.of("--network", "none")); + assertThat(command).containsSubsequence(List.of("--memory", "256m")); + assertThat(command).containsSubsequence(List.of("--cpus", "1.0")); + assertThat(command).containsSubsequence(List.of("--pids-limit", "128")); + assertThat(command).contains("--read-only"); + assertThat(command).containsSubsequence(List.of("--tmpfs", "/tmp:rw,size=64m")); + assertThat(command).containsSubsequence(List.of("--cap-drop", "ALL")); + assertThat(command).containsSubsequence(List.of("--security-opt", "no-new-privileges")); + assertThat(command).containsSubsequence(List.of("--log-driver", "none")); + } + + @Test + void workspaceIsMountedAndScriptUsesContainerPath() { + List command = plan().command(); + assertThat(command).containsSubsequence( + List.of("-v", workspace + ":" + DockerScriptCommandBuilder.CONTAINER_WORKSPACE)); + assertThat(command).containsSubsequence(List.of("-w", DockerScriptCommandBuilder.CONTAINER_WORKSPACE)); + assertThat(command).contains("/workspace/scripts/greet.sh"); + assertThat(command).endsWith("alpine:3.20", "sh", "/workspace/scripts/greet.sh", "arg1", "arg two"); + } + + @Test + void environmentIsPassedViaFlagsWithHomeRemapped() { + List command = plan().command(); + assertThat(command).contains("PATH=/usr/bin:/bin"); + assertThat(command).contains("HOME=" + DockerScriptCommandBuilder.CONTAINER_WORKSPACE); + assertThat(command).contains("LANG=C.UTF-8"); + // the docker CLI keeps its own environment to reach the daemon + assertThat(plan().environment()).isNull(); + } + + @Test + void containerNamesAreDockerSafe() { + assertThat(DockerScriptCommandBuilder.containerName("42-greet")).isEqualTo("skillhub-validation-42-greet"); + assertThat(DockerScriptCommandBuilder.containerName("7-task/with spaces")) + .isEqualTo("skillhub-validation-7-task-with-spaces"); + assertThat(DockerScriptCommandBuilder.containerName("task")) + .isEqualTo("skillhub-validation-task"); + } + + @Test + void dockerLimitsComeFromConfiguration() { + properties.getLocalScript().getDocker().setImage("python:3.12-alpine"); + properties.getLocalScript().getDocker().setMemory("512m"); + properties.getLocalScript().getDocker().setCpus("2.0"); + List command = plan().command(); + assertThat(command).containsSubsequence(List.of("--memory", "512m")); + assertThat(command).containsSubsequence(List.of("--cpus", "2.0")); + assertThat(command).contains("python:3.12-alpine"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptRuntimeAdapterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptRuntimeAdapterTest.java new file mode 100644 index 000000000..11d97c94e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptRuntimeAdapterTest.java @@ -0,0 +1,140 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext; +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Executes real behavior tasks through the docker backend and proves the + * isolation profile inside the container: no network interfaces beyond loopback, + * read-only root filesystem, and the workspace mounted writable. Skipped + * silently when no docker daemon is reachable, or when the daemon resolves + * bind-mount sources against a filesystem where the workspace does not exist + * (CI runners whose job container shares the host daemon behave this way). + */ +class DockerScriptRuntimeAdapterTest { + + @TempDir + Path workspace; + + private final AuthoringProperties properties = new AuthoringProperties(); + + @BeforeAll + static void requireDockerWithImage() throws Exception { + DockerScriptCommandBuilder probe = new DockerScriptCommandBuilder(new AuthoringProperties()); + Assumptions.assumeTrue(probe.available(), "docker daemon not reachable"); + try { + Process pull = new ProcessBuilder("docker", "image", "inspect", "alpine:3.20").start(); + if (pull.waitFor() != 0) { + new ProcessBuilder("docker", "pull", "alpine:3.20").start().waitFor(); + } + } catch (Exception exception) { + Assumptions.assumeTrue(false, "alpine image unavailable: " + exception.getMessage()); + } + // Round-trip a bind mount: on shared-daemon CI runners the -v source is + // resolved against the host filesystem and comes up empty, which is an + // environment limitation, not something these tests should fail on. + Path probeDir = Files.createTempDirectory("docker-mount-probe"); + Path probeFile = probeDir.resolve("probe.txt"); + Files.writeString(probeFile, "bind-mount-OK"); + try { + Process roundTrip = new ProcessBuilder("docker", "run", "--rm", + "-v", probeDir + ":/probe", "alpine:3.20", "cat", "/probe/probe.txt") + .redirectErrorStream(true).start(); + String output = new String(roundTrip.getInputStream().readAllBytes()); + roundTrip.waitFor(); + Assumptions.assumeTrue(output.contains("bind-mount-OK"), + "docker bind-mounts do not round-trip on this host (shared-daemon runner?)"); + } finally { + Files.deleteIfExists(probeFile); + Files.deleteIfExists(probeDir); + } + } + + private LocalScriptRuntimeAdapter dockerAdapter() { + properties.getLocalScript().setExecutionMode(AuthoringProperties.ScriptExecutionMode.DOCKER); + return new LocalScriptRuntimeAdapter(properties, List.of( + new InlineScriptCommandBuilder(), new DockerScriptCommandBuilder(properties))); + } + + private record RecordingSink(List logLines) implements RuntimeEventSink { + RecordingSink() { + this(new ArrayList<>()); + } + + @Override + public void agentMessage(String taskName, String content) { + } + + @Override + public void toolCall(String taskName, String tool, String argumentsJson) { + } + + @Override + public void toolResult(String taskName, String tool, String summaryJson) { + } + + @Override + public void log(String taskName, String stream, String line) { + logLines.add(line); + } + } + + private TaskResult runScript(String script) throws Exception { + Files.createDirectories(workspace.resolve("scripts")); + Files.writeString(workspace.resolve("scripts/task.sh"), script); + ValidationTaskSpec task = new ValidationTaskSpec("probe", "isolation probe", + com.iflytek.skillhub.domain.authoring.spec.TaskType.SCRIPT, + "scripts/task.sh", List.of(), null, 60_000, List.of()); + RuntimeExecutionContext context = new RuntimeExecutionContext( + 99L, workspace, Map.of("interpreter", "sh"), List.of(), () -> false); + return dockerAdapter().execute(context, task, new RecordingSink()); + } + + @Test + void runsScriptAndCapturesOutput() throws Exception { + TaskResult result = runScript("echo hello from docker\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout()).contains("hello from docker"); + } + + @Test + void containerHasNoNetworkRoutes() throws Exception { + // with --network none the routing table is empty (header line only), + // so nothing beyond loopback is reachable + TaskResult result = runScript("tail -n +2 /proc/net/route | wc -l | tr -d ' '\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout().trim()).isEqualTo("0"); + } + + @Test + void rootFilesystemIsReadOnly() throws Exception { + TaskResult result = runScript( + "if touch /etc/forbidden 2>/dev/null; then echo writable; else echo readonly; fi\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout()).contains("readonly").doesNotContain("writable"); + } + + @Test + void workspaceIsWritableForArtifacts() throws Exception { + TaskResult result = runScript("mkdir -p artifacts && echo data > artifacts/out.txt && cat artifacts/out.txt\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout()).contains("data"); + assertThat(result.artifacts().read("artifacts/out.txt")).isPresent(); + assertThat(new String(result.artifacts().read("artifacts/out.txt").orElseThrow())) + .contains("data"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapterToolLoopTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapterToolLoopTest.java new file mode 100644 index 000000000..f5d73f0e9 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapterToolLoopTest.java @@ -0,0 +1,281 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink; +import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext; +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import com.iflytek.skillhub.service.authoring.TestingAuthoringSecurityPolicy; +import com.iflytek.skillhub.service.authoring.mcp.McpClientFactory; +import com.iflytek.skillhub.service.authoring.mcp.TestingMcpHttpServer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Drives the full prompt-task agent loop against a fake OpenAI-compatible + * endpoint and a fake MCP server: tools are discovered over MCP, exposed to the + * model, executed for real when called, and every step lands in the trace sink. + * Also pins the toolFilters / toolAllowlist restrictions on what gets exposed. + */ +class OpenAiCompatibleRuntimeAdapterToolLoopTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TempDir + Path workspace; + + private TestingMcpHttpServer mcpServer; + private FakeOpenAiEndpoint openAi; + private final List trace = new ArrayList<>(); + private OpenAiCompatibleRuntimeAdapter adapter; + private AuthoringProperties properties; + + private record TraceEvent(String kind, String tool, String payload) { + } + + /** Records toolCall/toolResult/agentMessage/log in order. */ + private final RuntimeEventSink sink = new RuntimeEventSink() { + @Override + public void agentMessage(String taskName, String content) { + trace.add(new TraceEvent("agentMessage", null, content)); + } + + @Override + public void toolCall(String taskName, String tool, String argumentsJson) { + trace.add(new TraceEvent("toolCall", tool, argumentsJson)); + } + + @Override + public void toolResult(String taskName, String tool, String summaryJson) { + trace.add(new TraceEvent("toolResult", tool, summaryJson)); + } + + @Override + public void log(String taskName, String stream, String line) { + trace.add(new TraceEvent("log", stream, line)); + } + }; + + @BeforeEach + void start() throws Exception { + mcpServer = new TestingMcpHttpServer(List.of("get_forecast", "lookup_city")); + openAi = new FakeOpenAiEndpoint(); + properties = new AuthoringProperties(); + properties.getOpenAiCompatible().setEnabled(true); + adapter = new OpenAiCompatibleRuntimeAdapter(properties, MAPPER, + new McpClientFactory(MAPPER, properties, TestingAuthoringSecurityPolicy.permissive()), + TestingAuthoringSecurityPolicy.permissive()); + Files.writeString(workspace.resolve("SKILL.md"), "---\nname: test\n---\nUse the weather tools.\n"); + trace.clear(); + } + + @AfterEach + void stop() { + if (mcpServer != null) { + mcpServer.close(); + } + if (openAi != null) { + openAi.close(); + } + } + + private Map weatherServer() { + return Map.of("name", "weather", "transport", "http", "endpoint", mcpServer.endpoint()); + } + + private TaskResult run(List> mcpServers, List allowlist) throws Exception { + ValidationTaskSpec task = new ValidationTaskSpec("ask", "asks the weather", + TaskType.PROMPT, null, List.of(), "What is the weather in Paris?", 30_000, List.of()); + RuntimeExecutionContext context = new RuntimeExecutionContext(99L, workspace, + Map.of("endpoint", openAi.baseUrl() + "/", "model", "test-model"), + allowlist, mcpServers, () -> false); + return adapter.execute(context, task, sink); + } + + private static ObjectNode assistantMessage(String content) { + ObjectNode message = MAPPER.createObjectNode(); + message.put("role", "assistant"); + message.put("content", content); + return message; + } + + private static ObjectNode toolCallMessage(String functionName, String arguments) { + ObjectNode message = MAPPER.createObjectNode(); + message.put("role", "assistant"); + message.put("content", ""); + ObjectNode call = message.putArray("tool_calls").addObject(); + call.put("id", "call-1"); + call.put("type", "function"); + call.putObject("function").put("name", functionName).put("arguments", arguments); + return message; + } + + @Test + void executesMcpToolAndTracesEveryStep() throws Exception { + openAi.script(List.of( + toolCallMessage("weather__get_forecast", "{\"city\":\"Paris\"}"), + assistantMessage("The forecast for Paris is sunny."))); + + TaskResult result = run(List.of(weatherServer()), List.of()); + + assertThat(result.response()).isEqualTo("The forecast for Paris is sunny."); + assertThat(result.toolCallCount()).isEqualTo(1); + + // the real MCP server saw the call with the model's arguments + assertThat(mcpServer.toolCalls).hasSize(1); + assertThat(mcpServer.toolCalls.get(0).getKey()).isEqualTo("get_forecast"); + assertThat(mcpServer.toolCalls.get(0).getValue().path("city").asText()).isEqualTo("Paris"); + + // two chat rounds; the second carries the tool reply back to the model + assertThat(openAi.requests).hasSize(2); + assertThat(exposedFunctionNames(0)).containsExactlyInAnyOrder( + "weather__get_forecast", "weather__lookup_city"); + JsonNode secondMessages = openAi.requests.get(1).path("messages"); + assertThat(secondMessages).anySatisfy(message -> { + assertThat(message.path("role").asText()).isEqualTo("tool"); + assertThat(message.path("tool_call_id").asText()).isEqualTo("call-1"); + assertThat(message.path("content").asText()).contains("result of get_forecast"); + }); + + // the trace shows both chat rounds, the tool invocation, and the result + assertThat(trace).extracting(TraceEvent::kind) + .containsExactly("toolCall", "toolCall", "toolResult", "toolCall", "agentMessage"); + assertThat(trace.get(1).tool()).isEqualTo("mcp:weather/get_forecast"); + assertThat(trace.get(1).payload()).contains("Paris"); + assertThat(trace.get(2).tool()).isEqualTo("mcp:weather/get_forecast"); + assertThat(trace.get(2).payload()).contains("\"ok\":true"); + } + + @Test + void toolAllowlistRestrictsExposedTools() throws Exception { + openAi.script(List.of(assistantMessage("I only have the lookup tool."))); + + run(List.of(weatherServer()), List.of("lookup_city")); + + assertThat(openAi.requests).hasSize(1); + assertThat(exposedFunctionNames(0)).containsExactly("weather__lookup_city"); + } + + @Test + void serverToolFiltersRestrictExposedTools() throws Exception { + openAi.script(List.of(assistantMessage("done"))); + + Map filtered = new java.util.HashMap<>(weatherServer()); + filtered.put("toolFilters", List.of("get_forecast")); + run(List.of(filtered), List.of()); + + assertThat(exposedFunctionNames(0)).containsExactly("weather__get_forecast"); + } + + @Test + void withoutMcpServersNoToolsAreSentAndASingleRoundRuns() throws Exception { + openAi.script(List.of(assistantMessage("plain answer"))); + + TaskResult result = run(List.of(), List.of()); + + assertThat(result.response()).isEqualTo("plain answer"); + assertThat(result.toolCallCount()).isZero(); + assertThat(openAi.requests).hasSize(1); + assertThat(openAi.requests.get(0).has("tools")).isFalse(); + } + + @Test + void roundLimitCapsTheToolLoop() throws Exception { + properties.getOpenAiCompatible().setMaxToolRounds(2); + openAi.script(List.of( + toolCallMessage("weather__get_forecast", "{\"city\":\"Paris\"}"), + toolCallMessage("weather__get_forecast", "{\"city\":\"Rome\"}"), + toolCallMessage("weather__get_forecast", "{\"city\":\"Oslo\"}"))); + + TaskResult result = run(List.of(weatherServer()), List.of()); + + assertThat(result.toolCallCount()).isEqualTo(2); + assertThat(openAi.requests).hasSize(2); + assertThat(trace).anySatisfy(event -> { + assertThat(event.kind()).isEqualTo("log"); + assertThat(event.payload()).contains("round limit"); + }); + } + + private List exposedFunctionNames(int requestIndex) { + List names = new ArrayList<>(); + for (JsonNode tool : openAi.requests.get(requestIndex).path("tools")) { + names.add(tool.path("function").path("name").asText()); + } + return names; + } + + // ---------------------------------------------------------------- fake endpoint + + /** Serves scripted chat-completion responses and records every request body. */ + private static final class FakeOpenAiEndpoint implements AutoCloseable { + + private final HttpServer server; + private final List requests = new CopyOnWriteArrayList<>(); + private final List scriptedResponses = new CopyOnWriteArrayList<>(); + + FakeOpenAiEndpoint() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + } + + String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/v1"; + } + + void script(List assistantMessages) { + scriptedResponses.addAll(assistantMessages); + } + + private void handle(HttpExchange exchange) throws IOException { + try { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + requests.add((ObjectNode) MAPPER.readTree(body)); + ObjectNode message = scriptedResponses.isEmpty() + ? assistantMessage("no script left") : scriptedResponses.remove(0); + ObjectNode choice = MAPPER.createObjectNode(); + choice.put("index", 0); + choice.set("message", message); + choice.put("finish_reason", "stop"); + ObjectNode response = MAPPER.createObjectNode(); + response.put("id", "chatcmpl-test"); + response.putArray("choices").add(choice); + byte[] bytes = response.toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } finally { + exchange.close(); + } + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClientTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClientTest.java new file mode 100644 index 000000000..404dab1b2 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClientTest.java @@ -0,0 +1,172 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayInputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Exercises HttpMcpClient against a real local HTTP server implementing the MCP + * streamable-HTTP protocol, including the session-id contract and SSE-framed + * responses. + */ +class HttpMcpClientTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final HttpClient httpClient = HttpClient.newHttpClient(); + + private TestingMcpHttpServer mcpServer; + + @BeforeEach + void startServer() throws Exception { + mcpServer = new TestingMcpHttpServer(List.of("get_forecast", "lookup_city")); + } + + @AfterEach + void stopServer() { + if (mcpServer != null) { + mcpServer.close(); + } + } + + private HttpMcpClient client() { + return new HttpMcpClient("weather", mcpServer.endpoint(), httpClient, objectMapper, Map.of()); + } + + @Test + void initializesListsToolsAndCallsTools() throws Exception { + try (HttpMcpClient client = client()) { + List tools = client.listTools(Duration.ofSeconds(5)); + assertThat(tools).extracting(McpTool::name) + .containsExactly("get_forecast", "lookup_city"); + assertThat(tools.get(0).description()).isEqualTo("test tool get_forecast"); + + String output = client.callTool("get_forecast", + Map.of("city", "Paris"), Duration.ofSeconds(5)); + assertThat(output).isEqualTo("result of get_forecast: {\"city\":\"Paris\"}"); + assertThat(mcpServer.toolCalls).hasSize(1); + } + assertThat(mcpServer.initializeCount.get()).isEqualTo(1); + } + + @Test + void sessionHeaderIsReusedAfterInitialize() throws Exception { + // the fake server answers tools/list with 400 unless the Mcp-Session-Id + // captured during initialize is replayed — so a successful listing proves it + try (HttpMcpClient client = client()) { + assertThat(client.listTools(Duration.ofSeconds(5))).hasSize(2); + } + assertThat(mcpServer.sawRequestWithoutSessionId) + .as("tools/list must carry the session id from initialize") + .isFalse(); + } + + @Test + void parsesSseFramedResponses() throws Exception { + try (TestingMcpHttpServer sseServer = + new TestingMcpHttpServer(List.of("echo"), true); + HttpMcpClient client = new HttpMcpClient("sse", sseServer.endpoint(), + httpClient, objectMapper, Map.of())) { + List tools = client.listTools(Duration.ofSeconds(5)); + assertThat(tools).extracting(McpTool::name).containsExactly("echo"); + } + } + + @Test + void toolErrorResultBecomesException() throws Exception { + try (HttpMcpClient client = client()) { + client.listTools(Duration.ofSeconds(5)); + assertThatThrownBy(() -> client.callTool("boom", Map.of(), Duration.ofSeconds(5))) + .isInstanceOf(java.io.IOException.class) + .hasMessageContaining("boom"); + } + } + + @Test + void unreachableServerFailsFastWithClearMessage() { + try (HttpMcpClient client = new HttpMcpClient("dead", + "http://127.0.0.1:9/mcp", httpClient, objectMapper, Map.of())) { + assertThatThrownBy(() -> client.listTools(Duration.ofSeconds(5))) + .isInstanceOf(Exception.class) + .hasMessageContaining("dead"); + } + } + + @Test + void redirectsAreNeverFollowed() throws Exception { + // a public URL answering 302 towards a link-local target is the classic + // SSRF bypass: the client must surface the redirect as an error, not follow it + AtomicInteger requests = new AtomicInteger(); + HttpServer redirector = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + redirector.createContext("/", exchange -> { + requests.incrementAndGet(); + exchange.getResponseHeaders().set("Location", "http://169.254.169.254/mcp"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + }); + redirector.start(); + try (HttpMcpClient client = new HttpMcpClient("redirector", + "http://127.0.0.1:" + redirector.getAddress().getPort() + "/mcp", + httpClient, objectMapper, Map.of())) { + assertThatThrownBy(() -> client.listTools(Duration.ofSeconds(5))) + .isInstanceOf(Exception.class) + .hasMessageContaining("302"); + } finally { + redirector.stop(0); + } + assertThat(requests.get()) + .as("the redirect target must never be requested") + .isEqualTo(1); + } + + @Test + void oversizedResponseBodyIsRejected() throws Exception { + // 3 MiB body against a 2 MiB cap: a hostile endpoint must not be able to + // buffer an unbounded response in server memory + HttpServer flood = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + flood.createContext("/", exchange -> { + byte[] bytes = new byte[3 * 1024 * 1024]; + java.util.Arrays.fill(bytes, (byte) 'x'); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + exchange.close(); + }); + flood.start(); + try (HttpMcpClient client = new HttpMcpClient("flood", + "http://127.0.0.1:" + flood.getAddress().getPort() + "/mcp", + httpClient, objectMapper, Map.of())) { + assertThatThrownBy(() -> client.listTools(Duration.ofSeconds(10))) + .isInstanceOf(Exception.class) + .hasMessageContaining("exceeds"); + } finally { + flood.stop(0); + } + } + + @Test + void readBodyCappedAllowsBodiesUnderTheLimit() throws Exception { + byte[] small = "hello".getBytes(StandardCharsets.UTF_8); + assertThat(HttpMcpClient.readBodyCapped(new ByteArrayInputStream(small), 10)) + .isEqualTo("hello"); + assertThatThrownBy(() -> HttpMcpClient.readBodyCapped( + new ByteArrayInputStream(small), 3)) + .isInstanceOf(java.io.IOException.class) + .hasMessageContaining("exceeds"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactoryTest.java new file mode 100644 index 000000000..bcd4fb3df --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactoryTest.java @@ -0,0 +1,148 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.service.AuthoringSecurityPolicy; +import com.iflytek.skillhub.service.authoring.TestingAuthoringSecurityPolicy; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Connect-time enforcement in McpClientFactory: blocked endpoints and the + * disabled stdio transport fail before any connection or process is created, + * envRefs are filtered through the policy, and docker execution mode wraps + * stdio commands in the hardened container profile. + */ +class McpClientFactoryTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final AuthoringProperties properties = new AuthoringProperties(); + + private McpClientFactory factory(AuthoringSecurityPolicy policy) { + return new McpClientFactory(MAPPER, properties, policy); + } + + /** Allows everything except env refs (for the envRefs filter test). */ + private final AuthoringSecurityPolicy pathOnlyEnvRefs = new AuthoringSecurityPolicy() { + @Override + public String endpointRejection(String url, EndpointUse use) { + return null; + } + + @Override + public boolean stdioTransportAllowed() { + return true; + } + + @Override + public boolean envRefAllowed(String name) { + return "PATH".equals(name); + } + }; + + @Test + void httpEndpointRejectedByPolicyNeverConnects() { + AuthoringSecurityPolicy blocking = new AuthoringSecurityPolicy() { + @Override + public String endpointRejection(String url, EndpointUse use) { + return "blocked by test policy"; + } + + @Override + public boolean stdioTransportAllowed() { + return true; + } + + @Override + public boolean envRefAllowed(String name) { + return true; + } + }; + assertThatThrownBy(() -> factory(blocking).create(Map.of( + "name", "evil", "transport", "http", "endpoint", "http://169.254.169.254/mcp"))) + .isInstanceOf(IOException.class) + .hasMessageContaining("endpoint rejected"); + } + + @Test + void stdioIsRefusedWhenDisabled() { + AuthoringSecurityPolicy stdioDisabled = new AuthoringSecurityPolicy() { + @Override + public String endpointRejection(String url, EndpointUse use) { + return null; + } + + @Override + public boolean stdioTransportAllowed() { + return false; + } + + @Override + public boolean envRefAllowed(String name) { + return true; + } + }; + assertThatThrownBy(() -> factory(stdioDisabled).create( + Map.of("name", "local", "transport", "stdio", "command", "sleep 30"))) + .isInstanceOf(IOException.class) + .hasMessageContaining("stdio transport is disabled"); + } + + @Test + void envRefsNotInAllowlistNeverReachTheSpawnedProcess() { + // HOME exists in every environment, so dropping it is observable: only + // the allowlisted PATH survives the filter + Map server = Map.of( + "name", "envtest", "transport", "stdio", "command", "sleep 30", + "envRefs", List.of("PATH", "HOME")); + Map environment = factory(pathOnlyEnvRefs).environmentOf(server); + assertThat(environment).containsKey("PATH"); + assertThat(environment).doesNotContainKey("HOME"); + } + + @Test + void dockerModeWrapsStdioCommandInHardenedContainer() { + properties.getMcp().setStdioEnabled(true); + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.DOCKER); + properties.getMcp().getDocker().setImage("alpine:3.20"); + + List wrapped = factory(TestingAuthoringSecurityPolicy.permissive()) + .dockerWrappedCommand("skillhub-mcp-test-abcd1234", + List.of("python3", "-m", "fake_server"), + Map.of("WEATHER_API_KEY", "secret-value")); + + assertThat(wrapped.get(0)).isEqualTo("docker"); + assertThat(wrapped).containsSubsequence("run", "--rm", "--name", + "skillhub-mcp-test-abcd1234"); + assertThat(wrapped).containsSubsequence("--network", "none"); + assertThat(wrapped).containsSubsequence("--memory", "256m"); + assertThat(wrapped).containsSubsequence("--cpus", "1.0"); + assertThat(wrapped).containsSubsequence("--pids-limit", "128"); + assertThat(wrapped).contains("--read-only"); + assertThat(wrapped).containsSubsequence("--cap-drop", "ALL"); + assertThat(wrapped).contains("no-new-privileges"); + // environment travels as -e flags, not through the parent environment + assertThat(wrapped).containsSubsequence("-e", "WEATHER_API_KEY=secret-value"); + // the image runs the declared command + int image = wrapped.indexOf("alpine:3.20"); + assertThat(image).isPositive(); + assertThat(wrapped.subList(image + 1, wrapped.size())) + .containsExactly("python3", "-m", "fake_server"); + } + + @Test + void containerNamesAreSanitizedAndUnique() { + String first = McpClientFactory.containerName("my server!"); + String second = McpClientFactory.containerName("my server!"); + assertThat(first).startsWith("skillhub-mcp-my-server-"); + assertThat(first).isNotEqualTo(second); + assertThat(McpClientFactory.containerName(null)).startsWith("skillhub-mcp-unknown-"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeServiceTest.java new file mode 100644 index 000000000..85e825e55 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeServiceTest.java @@ -0,0 +1,98 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.FindingSeverity; +import com.iflytek.skillhub.service.authoring.TestingAuthoringSecurityPolicy; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * McpProbeService turns MCP declarations into verified facts: reachable servers + * with their discovered tools, and findings for unreachable servers or filters + * naming tools the server does not expose. + */ +class McpProbeServiceTest { + + private final McpProbeService probeService = new McpProbeService(new McpClientFactory( + new ObjectMapper(), new AuthoringProperties(), + TestingAuthoringSecurityPolicy.permissive())); + + private TestingMcpHttpServer mcpServer; + + @BeforeEach + void startServer() throws Exception { + mcpServer = new TestingMcpHttpServer(List.of("get_forecast", "lookup_city")); + } + + @AfterEach + void stopServer() { + if (mcpServer != null) { + mcpServer.close(); + } + } + + private Map server(Map overrides) { + Map server = new java.util.HashMap<>(); + server.put("name", "weather"); + server.put("transport", "http"); + server.put("endpoint", mcpServer.endpoint()); + server.putAll(overrides); + return server; + } + + @Test + void reachableServerReportsDiscoveredToolsWithoutFindings() { + McpProbeService.ProbeReport report = probeService.probe( + List.of(server(Map.of())), Duration.ofSeconds(5)); + assertThat(report.servers()).hasSize(1); + assertThat(report.servers().get(0).connected()).isTrue(); + assertThat(report.servers().get(0).tools()) + .containsExactly("get_forecast", "lookup_city"); + assertThat(report.findings()).isEmpty(); + } + + @Test + void unreachableServerProducesConnectFailedError() { + Map dead = Map.of( + "name", "dead", "transport", "http", "endpoint", "http://127.0.0.1:9/mcp"); + McpProbeService.ProbeReport report = probeService.probe(List.of(dead), Duration.ofSeconds(5)); + assertThat(report.servers().get(0).connected()).isFalse(); + assertThat(report.findings()).hasSize(1); + FindingDraft finding = report.findings().get(0); + assertThat(finding.ruleCode()).isEqualTo("MCP_CONNECT_FAILED"); + assertThat(finding.severity()).isEqualTo(FindingSeverity.ERROR); + assertThat(finding.message()).contains("dead"); + } + + @Test + void unknownToolFilterProducesWarningButStaysConnected() { + McpProbeService.ProbeReport report = probeService.probe( + List.of(server(Map.of("toolFilters", List.of("get_forecast", "does_not_exist")))), + Duration.ofSeconds(5)); + assertThat(report.servers().get(0).connected()).isTrue(); + assertThat(report.findings()).hasSize(1); + FindingDraft finding = report.findings().get(0); + assertThat(finding.ruleCode()).isEqualTo("MCP_TOOL_FILTER_UNKNOWN"); + assertThat(finding.severity()).isEqualTo(FindingSeverity.WARNING); + assertThat(finding.message()).contains("does_not_exist").contains("get_forecast"); + } + + @Test + void malformedDeclarationsAreSkippedNotProbed() { + // name/transport problems are already reported by RuntimeBindingValidator; + // the probe must not duplicate them or crash + McpProbeService.ProbeReport report = probeService.probe( + List.of(Map.of("name", "weird", "transport", "carrier-pigeon")), + Duration.ofSeconds(5)); + assertThat(report.servers()).isEmpty(); + assertThat(report.findings()).isEmpty(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClientTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClientTest.java new file mode 100644 index 000000000..f918ceca1 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClientTest.java @@ -0,0 +1,133 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Runs StdioMcpClient against a python3 process implementing the stdio transport + * (newline-delimited JSON-RPC on stdin/stdout). Skipped when python3 is absent. + */ +class StdioMcpClientTest { + + private static final String FAKE_SERVER = """ + import json, sys + def send(obj): + sys.stdout.write(json.dumps(obj) + "\\n") + sys.stdout.flush() + for line in sys.stdin: + line = line.strip() + if not line: + continue + msg = json.loads(line) + if 'id' not in msg: + continue + method = msg.get('method') + if method == 'initialize': + send({'jsonrpc': '2.0', 'id': msg['id'], 'result': { + 'protocolVersion': '2024-11-05', 'capabilities': {}, + 'serverInfo': {'name': 'fake', 'version': '1.0'}}}) + elif method == 'tools/list': + send({'jsonrpc': '2.0', 'id': msg['id'], 'result': {'tools': [ + {'name': 'echo', 'description': 'echo text', + 'inputSchema': {'type': 'object'}}]}}) + elif method == 'tools/call': + args = msg['params']['arguments'] + send({'jsonrpc': '2.0', 'id': msg['id'], 'result': {'content': [ + {'type': 'text', 'text': 'echo:' + args.get('text', '')}]}}) + """; + + @BeforeAll + static void requirePython3() { + try { + int exit = new ProcessBuilder("python3", "--version").start().waitFor(); + Assumptions.assumeTrue(exit == 0, "python3 unavailable"); + } catch (Exception exception) { + Assumptions.assumeTrue(false, "python3 unavailable: " + exception.getMessage()); + } + } + + private StdioMcpClient client() throws Exception { + return new StdioMcpClient("echo-server", List.of("python3", "-c", FAKE_SERVER), + Map.of(), new ObjectMapper()); + } + + @Test + void listsToolsAndCallsThemOverStdio() throws Exception { + try (StdioMcpClient client = client()) { + List tools = client.listTools(Duration.ofSeconds(10)); + assertThat(tools).extracting(McpTool::name).containsExactly("echo"); + + String output = client.callTool("echo", Map.of("text", "hello"), Duration.ofSeconds(10)); + assertThat(output).isEqualTo("echo:hello"); + } + } + + @Test + void processIsTerminatedOnClose() throws Exception { + StdioMcpClient client = client(); + client.listTools(Duration.ofSeconds(10)); + client.close(); + // a second close must not hang or throw; the process is already gone + client.close(); + } + + @Test + void failingCommandSurfacesAsException() { + assertThatThrownBy(() -> new StdioMcpClient("broken", + List.of("definitely-not-a-real-command-xyz"), Map.of(), new ObjectMapper()) + .listTools(Duration.ofSeconds(10))) + .isInstanceOf(Exception.class); + } + + @Test + void unterminatedHugeLineIsRejectedAndTheProcessKilled() { + // a hostile stdio server streaming one endless line must not exhaust + // memory: the capped reader destroys the process past the limit + String floodServer = """ + import sys, time + sys.stdout.write('x' * (5 * 1024 * 1024)) + sys.stdout.flush() + time.sleep(60) + """; + StdioMcpClient client; + try { + client = new StdioMcpClient("flood", List.of("python3", "-c", floodServer), + Map.of(), new ObjectMapper()); + } catch (Exception exception) { + // python3 disappeared between the class check and here + org.junit.jupiter.api.Assumptions.assumeTrue(false, exception.getMessage()); + return; + } + assertThatThrownBy(() -> client.listTools(Duration.ofSeconds(30))) + .isInstanceOf(Exception.class) + .hasMessageContaining("longer than"); + client.close(); + } + + @Test + void teardownCommandRunsOnClose() throws Exception { + // docker-wrapped stdio servers rely on this: a killed docker client can + // leave its container behind, so close() must run the cleanup command + Path marker = Files.createTempFile("skillhub-stdio-teardown", ".marker"); + Files.deleteIfExists(marker); + StdioMcpClient client = new StdioMcpClient("cleaned", + List.of("python3", "-c", FAKE_SERVER), Map.of(), new ObjectMapper(), + List.of("touch", marker.toString()), false); + client.listTools(Duration.ofSeconds(10)); + client.close(); + assertThat(Files.exists(marker)) + .as("the teardown command must have run after process destruction") + .isTrue(); + Files.deleteIfExists(marker); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/TestingMcpHttpServer.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/TestingMcpHttpServer.java new file mode 100644 index 000000000..c2f9678aa --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/TestingMcpHttpServer.java @@ -0,0 +1,156 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Minimal MCP streamable-HTTP server for tests. Implements the initialize / + * notifications/initialized / tools/list / tools/call exchange the real client + * performs, can answer in plain JSON or SSE framing, enforces the session id on + * post-initialize requests, and records every tools/call it received. The tool + * named "boom" always answers with an error result. + */ +public class TestingMcpHttpServer implements AutoCloseable { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final HttpServer server; + private final List toolNames; + private final boolean sseMode; + + /** Every tools/call: tool name -> argument map, in arrival order. */ + public final List> toolCalls = new CopyOnWriteArrayList<>(); + /** Whether a post-initialize request arrived without the session id header. */ + public volatile boolean sawRequestWithoutSessionId; + /** How many initialize requests were handled. */ + public final AtomicInteger initializeCount = new AtomicInteger(); + + public TestingMcpHttpServer(List toolNames) throws IOException { + this(toolNames, false); + } + + public TestingMcpHttpServer(List toolNames, boolean sseMode) throws IOException { + this.toolNames = List.copyOf(toolNames); + this.sseMode = sseMode; + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + } + + public String endpoint() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/mcp"; + } + + private void handle(HttpExchange exchange) throws IOException { + try { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + JsonNode request = MAPPER.readTree(body); + String method = request.path("method").asText(""); + if ("initialize".equals(method)) { + initializeCount.incrementAndGet(); + respond(exchange, result(exchange, request, initializeResult()), "test-session-1"); + } else if ("notifications/initialized".equals(method)) { + exchange.sendResponseHeaders(202, -1); + } else if ("tools/list".equals(method)) { + if (exchange.getRequestHeaders().getFirst("Mcp-Session-Id") == null) { + sawRequestWithoutSessionId = true; + exchange.sendResponseHeaders(400, -1); + return; + } + respond(exchange, result(exchange, request, toolsListResult()), null); + } else if ("tools/call".equals(method)) { + if (exchange.getRequestHeaders().getFirst("Mcp-Session-Id") == null) { + sawRequestWithoutSessionId = true; + exchange.sendResponseHeaders(400, -1); + return; + } + String tool = request.path("params").path("name").asText(); + toolCalls.add(Map.entry(tool, request.path("params").path("arguments"))); + respond(exchange, result(exchange, request, toolCallResult(tool, + request.path("params").path("arguments"))), null); + } else { + exchange.sendResponseHeaders(404, -1); + } + } finally { + exchange.close(); + } + } + + private ObjectNode initializeResult() { + ObjectNode result = MAPPER.createObjectNode(); + result.put("protocolVersion", "2024-11-05"); + result.set("capabilities", MAPPER.createObjectNode()); + result.putObject("serverInfo").put("name", "testing-mcp").put("version", "1.0"); + return result; + } + + private ObjectNode toolsListResult() { + ObjectNode result = MAPPER.createObjectNode(); + ArrayNode tools = result.putArray("tools"); + for (String name : toolNames) { + ObjectNode tool = tools.addObject(); + tool.put("name", name); + tool.put("description", "test tool " + name); + tool.putObject("inputSchema").put("type", "object"); + } + return result; + } + + private ObjectNode toolCallResult(String tool, JsonNode arguments) { + ObjectNode result = MAPPER.createObjectNode(); + if ("boom".equals(tool)) { + result.put("isError", true); + result.putArray("content").addObject().put("type", "text").put("text", "tool exploded"); + return result; + } + result.putArray("content").addObject().put("type", "text") + .put("text", "result of " + tool + ": " + arguments); + return result; + } + + private ObjectNode result(HttpExchange exchange, JsonNode request, ObjectNode payload) { + ObjectNode response = MAPPER.createObjectNode(); + response.put("jsonrpc", "2.0"); + response.put("id", request.path("id").asInt()); + response.set("result", payload); + return response; + } + + private void respond(HttpExchange exchange, ObjectNode response, String sessionId) + throws IOException { + byte[] bytes; + String contentType; + if (sseMode) { + bytes = ("event: message\ndata: " + response + "\n\n").getBytes(StandardCharsets.UTF_8); + contentType = "text/event-stream"; + } else { + bytes = response.toString().getBytes(StandardCharsets.UTF_8); + contentType = "application/json"; + } + if (sessionId != null) { + exchange.getResponseHeaders().set("Mcp-Session-Id", sessionId); + } + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + @Override + public void close() { + server.stop(0); + } +} diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index 8749a3557..fb78830ff 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -47,6 +47,19 @@ skillhub: security: scanner: enabled: false + authoring: + local-script: + # tests exercise the inline script runtime directly; no docker needed + execution-mode: inline + openai-compatible: + # fake LLM endpoints in tests bind to loopback + allow-private-endpoints: true + mcp: + # fake MCP servers in tests bind to loopback; link-local stays blocked + allow-private-endpoints: true + stdio-enabled: true + # the one variable bindings may reference in tests; everything else is rejected + env-allowlist: TEST_ALLOWED_MCP_VAR logging: level: diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/AgentRuntimeType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/AgentRuntimeType.java new file mode 100644 index 000000000..c412a6c8b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/AgentRuntimeType.java @@ -0,0 +1,33 @@ +package com.iflytek.skillhub.domain.authoring; + +/** + * Runtime types a draft can bind for behavior validation. The registry of supported + * adapters lives in the application layer; the domain only knows the stable identifiers. + */ +public enum AgentRuntimeType { + + /** Executes the skill's own scripts in an isolated working directory. */ + LOCAL_SCRIPT("local-script"), + + /** Sends prompts to an OpenAI-compatible chat completion endpoint with SKILL.md as context. */ + OPENAI_COMPATIBLE("openai-compatible"); + + private final String identifier; + + AgentRuntimeType(String identifier) { + this.identifier = identifier; + } + + public String identifier() { + return identifier; + } + + public static AgentRuntimeType fromIdentifier(String identifier) { + for (AgentRuntimeType type : values()) { + if (type.identifier.equals(identifier)) { + return type; + } + } + throw new IllegalArgumentException("Unknown agent runtime type: " + identifier); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFile.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFile.java new file mode 100644 index 000000000..1ff28c061 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFile.java @@ -0,0 +1,121 @@ +package com.iflytek.skillhub.domain.authoring; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; + +/** + * One file inside a skill draft. File bodies live in object storage under a + * content-addressed key; this record tracks identity, integrity digest, and size. + */ +@Entity +@Table(name = "draft_file", + uniqueConstraints = @jakarta.persistence.UniqueConstraint( + name = "uq_draft_file_path", columnNames = {"draft_id", "file_path"})) +public class DraftFile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "draft_id", nullable = false) + private Long draftId; + + @Column(name = "file_path", nullable = false, length = 512) + private String filePath; + + @Column(nullable = false, length = 64) + private String sha256; + + @Column(nullable = false) + private Long size; + + @Column(name = "content_type", length = 128) + private String contentType; + + @Column(name = "storage_key", nullable = false, length = 768) + private String storageKey; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected DraftFile() { + } + + public DraftFile(Long draftId, String filePath, String sha256, Long size, + String contentType, String storageKey) { + this.draftId = draftId; + this.filePath = filePath; + this.sha256 = sha256; + this.size = size; + this.contentType = contentType; + this.storageKey = storageKey; + } + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(Clock.systemUTC()); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(Clock.systemUTC()); + } + + public void updateContent(String sha256, Long size, String contentType, String storageKey) { + this.sha256 = sha256; + this.size = size; + this.contentType = contentType; + this.storageKey = storageKey; + } + + // Getters + + public Long getId() { + return id; + } + + public Long getDraftId() { + return draftId; + } + + public String getFilePath() { + return filePath; + } + + public String getSha256() { + return sha256; + } + + public Long getSize() { + return size; + } + + public String getContentType() { + return contentType; + } + + public String getStorageKey() { + return storageKey; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFileRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFileRepository.java new file mode 100644 index 000000000..beb43ddfe --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/DraftFileRepository.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.domain.authoring; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for draft files. + */ +public interface DraftFileRepository { + + DraftFile save(DraftFile file); + + List findByDraftIdOrderByFilePath(Long draftId); + + Optional findByDraftIdAndFilePath(Long draftId, String filePath); + + List findByDraftIdAndFilePathIn(Long draftId, List filePaths); + + void delete(DraftFile file); + + void deleteByDraftId(Long draftId); + + long countByDraftId(Long draftId); + + long sumSizeByDraftId(Long draftId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FilePatch.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FilePatch.java new file mode 100644 index 000000000..2ff133672 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FilePatch.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.domain.authoring; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.List; + +/** + * A machine-applicable file replacement. {@code oldValue} is the exact current content + * (null when the file does not exist yet); applying the patch replaces it with + * {@code newValue}. {@code oldSha256} guards against applying a suggestion built on a + * stale revision of the file. + */ +public record FilePatch(String filePath, String oldSha256, String oldValue, String newValue) { + + /** Derived helper, not part of the persisted shape. */ + @JsonIgnore + public boolean isCreation() { + return oldValue == null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FixSuggestion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FixSuggestion.java new file mode 100644 index 000000000..8cb78757b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/FixSuggestion.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.domain.authoring; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * A fix suggestion attached to a validation finding. Suggestions are proposals only: + * they are never applied without explicit user confirmation, and applying them always + * produces a new draft revision so history stays auditable. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public record FixSuggestion( + String description, + @JsonProperty("patches") List patches +) { + + public List safePatches() { + return patches == null ? List.of() : List.copyOf(patches); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBinding.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBinding.java new file mode 100644 index 000000000..c741569a5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBinding.java @@ -0,0 +1,134 @@ +package com.iflytek.skillhub.domain.authoring; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * Per-draft runtime configuration used by behavior validation. Holds the agent type, + * adapter-specific configuration, tool allowlist, and MCP server declarations. + * + *

Plain secrets must never be stored here: credentials are referenced by name and + * resolved from server-side configuration at validation time. The JSON columns are + * mapped as structured collections (not Strings) so the stored documents stay real + * JSON objects and Hibernate's read path is symmetric with its write path. + */ +@Entity +@Table(name = "runtime_binding", + uniqueConstraints = @jakarta.persistence.UniqueConstraint( + name = "uq_runtime_binding_draft", columnNames = "draft_id")) +public class RuntimeBinding { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "draft_id", nullable = false) + private Long draftId; + + @Enumerated(EnumType.STRING) + @Column(name = "agent_type", nullable = false, length = 32) + private AgentRuntimeType agentType; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "config") + private Map config; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "tool_allowlist") + private List toolAllowlist; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "mcp_servers") + private List> mcpServers; + + @Column(name = "updated_by", length = 128) + private String updatedBy; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected RuntimeBinding() { + } + + public RuntimeBinding(Long draftId, AgentRuntimeType agentType) { + this.draftId = draftId; + this.agentType = agentType; + } + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(Clock.systemUTC()); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(Clock.systemUTC()); + } + + public void update(AgentRuntimeType agentType, Map config, + List toolAllowlist, List> mcpServers, + String updatedBy) { + this.agentType = agentType; + this.config = config; + this.toolAllowlist = toolAllowlist; + this.mcpServers = mcpServers; + this.updatedBy = updatedBy; + } + + // Getters + + public Long getId() { + return id; + } + + public Long getDraftId() { + return draftId; + } + + public AgentRuntimeType getAgentType() { + return agentType; + } + + public Map getConfig() { + return config; + } + + public List getToolAllowlist() { + return toolAllowlist; + } + + public List> getMcpServers() { + return mcpServers; + } + + public String getUpdatedBy() { + return updatedBy; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBindingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBindingRepository.java new file mode 100644 index 000000000..6ff71050d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBindingRepository.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.domain.authoring; + +import java.util.Optional; + +/** + * Domain repository contract for per-draft runtime bindings. + */ +public interface RuntimeBindingRepository { + + RuntimeBinding save(RuntimeBinding binding); + + Optional findByDraftId(Long draftId); + + void deleteByDraftId(Long draftId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraft.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraft.java new file mode 100644 index 000000000..c8f3f8426 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraft.java @@ -0,0 +1,194 @@ +package com.iflytek.skillhub.domain.authoring; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Clock; +import java.time.Instant; + +/** + * A skill authoring draft owned by one user. Drafts hold editable SKILL.md and resource files, + * are content-addressed by {@code contentDigest}, and advance a monotonically increasing + * {@code revision} on every content change. A draft is only submittable when its current + * revision matches a revision with a passing validation run. + */ +@Entity +@Table(name = "skill_draft") +public class SkillDraft { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(name = "owner_id", nullable = false, length = 128) + private String ownerId; + + @Column(nullable = false, length = 128) + private String name; + + @Column(columnDefinition = "TEXT") + private String requirement; + + @Column(name = "revision", nullable = false) + private Integer revision = 1; + + @Column(name = "content_digest", nullable = false, length = 64) + private String contentDigest; + + @Column(name = "validated_revision") + private Integer validatedRevision; + + @Column(name = "validated_run_id") + private Long validatedRunId; + + @Column(name = "submitted_skill_id") + private Long submittedSkillId; + + @Column(name = "submitted_version_id") + private Long submittedVersionId; + + @Column(name = "submitted_at") + private Instant submittedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Version + @Column(name = "version", nullable = false) + private Long optimisticVersion; + + protected SkillDraft() { + } + + public SkillDraft(Long namespaceId, String ownerId, String name, String requirement, + String contentDigest) { + this.namespaceId = namespaceId; + this.ownerId = ownerId; + this.name = name; + this.requirement = requirement; + this.contentDigest = contentDigest; + } + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(Clock.systemUTC()); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(Clock.systemUTC()); + } + + /** + * Marks the draft content as changed: bumps the revision and attaches the new digest. + * Validation state is intentionally preserved — callers compare + * {@code validatedRevision} against {@code revision} to decide whether the draft + * is still considered validated. + */ + public void applyContentChange(int newRevision, String newContentDigest) { + this.revision = newRevision; + this.contentDigest = newContentDigest; + } + + public void markValidated(int validatedRevision, Long validatedRunId) { + this.validatedRevision = validatedRevision; + this.validatedRunId = validatedRunId; + } + + public void markSubmitted(Long skillId, Long versionId, Instant submittedAt) { + this.submittedSkillId = skillId; + this.submittedVersionId = versionId; + this.submittedAt = submittedAt; + } + + /** Returns true when the current revision has a passing validation run attached. */ + public boolean isCurrentRevisionValidated() { + return validatedRevision != null && validatedRevision.equals(revision); + } + + // Getters + + public Long getId() { + return id; + } + + public Long getNamespaceId() { + return namespaceId; + } + + public String getOwnerId() { + return ownerId; + } + + public String getName() { + return name; + } + + public String getRequirement() { + return requirement; + } + + public Integer getRevision() { + return revision; + } + + public String getContentDigest() { + return contentDigest; + } + + public Integer getValidatedRevision() { + return validatedRevision; + } + + public Long getValidatedRunId() { + return validatedRunId; + } + + public Long getSubmittedSkillId() { + return submittedSkillId; + } + + public Long getSubmittedVersionId() { + return submittedVersionId; + } + + public Instant getSubmittedAt() { + return submittedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Long getOptimisticVersion() { + return optimisticVersion; + } + + // Setters + + public void setName(String name) { + this.name = name; + } + + public void setRequirement(String requirement) { + this.requirement = requirement; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraftRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraftRepository.java new file mode 100644 index 000000000..87611eb6b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/SkillDraftRepository.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.domain.authoring; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for skill authoring drafts. + */ +public interface SkillDraftRepository { + + SkillDraft save(SkillDraft draft); + + Optional findById(Long id); + + List findByOwnerIdOrderByUpdatedAtDesc(String ownerId); + + List findByNamespaceId(Long namespaceId); + + /** Case-insensitive uniqueness of (owner, name) used at creation time. */ + Optional findByOwnerIdAndNameIgnoreCase(String ownerId, String name); + + void delete(SkillDraft draft); + + boolean existsByIdAndOwnerId(Long id, String ownerId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/package-info.java new file mode 100644 index 000000000..cc1135668 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/package-info.java @@ -0,0 +1,9 @@ +/** + * Skill authoring drafts: editable skill packages owned by one user, validated in a + * controlled runtime before being submitted to the publish pipeline. + * + *

Contains the draft, draft file, and runtime binding aggregates together with + * their repository contracts. Validation execution state lives in the + * {@code com.iflytek.skillhub.domain.authoring.validation} package. + */ +package com.iflytek.skillhub.domain.authoring; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluator.java new file mode 100644 index 000000000..981ace3b9 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluator.java @@ -0,0 +1,221 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.authoring.spec.AssertionSpec; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Evaluates rule-based assertions against a task result. Deterministic checks only: + * exit codes, regex/substring matching, JSON pointer equality, artifact presence and + * hashes, and tool call counts. Semantic quality checks are intentionally not + * approximated here — they belong to model-assisted review, which stays outside the + * minimal closed loop. + */ +public class AssertionEvaluator { + + /** Assertion types valid for SCRIPT tasks. */ + public static final List SCRIPT_ASSERTION_TYPES = List.of( + "exit_code", "stdout_matches", "stdout_contains", "stdout_json", + "artifact_exists", "tool_call_count"); + + /** Assertion types valid for PROMPT tasks. */ + public static final List PROMPT_ASSERTION_TYPES = List.of( + "response_matches", "response_contains", "response_json", "tool_call_count"); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Validates assertion types and required parameters without a result. Used by the + * configuration layer so malformed assertions fail fast, before any execution. + */ + public List validateSyntax(List assertions, boolean promptTask) { + List problems = new ArrayList<>(); + List allowed = promptTask ? PROMPT_ASSERTION_TYPES : SCRIPT_ASSERTION_TYPES; + for (AssertionSpec assertion : assertions) { + String type = assertion.type(); + if (type == null || type.isBlank()) { + problems.add("assertion without a type"); + continue; + } + if (!allowed.contains(type)) { + problems.add("assertion type '" + type + "' is not valid for " + + (promptTask ? "prompt" : "script") + " tasks (allowed: " + + String.join(", ", allowed) + ")"); + continue; + } + switch (type) { + case "exit_code" -> { + if (assertion.paramInt("equals") == null) { + problems.add("exit_code assertion requires an integer 'equals'"); + } + } + case "stdout_matches", "response_matches" -> { + String pattern = assertion.paramString("pattern"); + if (pattern == null || pattern.isBlank()) { + problems.add(type + " assertion requires a 'pattern'"); + } else if (!compiles(pattern)) { + problems.add(type + " assertion has an invalid regex: " + pattern); + } + } + case "stdout_contains", "response_contains" -> { + if (assertion.paramString("value") == null) { + problems.add(type + " assertion requires a 'value'"); + } + } + case "stdout_json", "response_json" -> { + if (assertion.paramString("pointer") == null) { + problems.add(type + " assertion requires a 'pointer'"); + } + if (assertion.paramString("equals") == null && assertion.paramInt("equals") == null) { + problems.add(type + " assertion requires an 'equals' value"); + } + } + case "artifact_exists" -> { + if (assertion.paramString("path") == null || assertion.paramString("path").isBlank()) { + problems.add("artifact_exists assertion requires a 'path'"); + } + } + case "tool_call_count" -> { + Integer min = assertion.paramInt("min"); + Integer max = assertion.paramInt("max"); + if (min == null && max == null) { + problems.add("tool_call_count assertion requires 'min' and/or 'max'"); + } + } + default -> problems.add("unknown assertion type: " + type); + } + } + return problems; + } + + /** Evaluates all assertions against the task result; empty list means all passed. */ + public List evaluate(List assertions, TaskResult result) { + List failures = new ArrayList<>(); + for (AssertionSpec assertion : assertions) { + String reason = evaluateOne(assertion, result); + if (reason != null) { + failures.add(new AssertionFailure(assertion, reason)); + } + } + return failures; + } + + private String evaluateOne(AssertionSpec assertion, TaskResult result) { + String type = assertion.type(); + return switch (type == null ? "" : type) { + case "exit_code" -> { + Integer expected = assertion.paramInt("equals"); + if (result.exitCode() == null) { + yield "task did not run to completion, no exit code"; + } + if (expected == null || result.exitCode() != expected) { + yield "expected exit code " + expected + " but got " + result.exitCode(); + } + yield null; + } + case "stdout_matches" -> matchPattern(assertion.paramString("pattern"), result.stdout(), "stdout"); + case "stdout_contains" -> containsValue(assertion.paramString("value"), result.stdout(), "stdout"); + case "response_matches" -> matchPattern(assertion.paramString("pattern"), result.response(), "response"); + case "response_contains" -> containsValue(assertion.paramString("value"), result.response(), "response"); + case "stdout_json" -> jsonPointer(assertion, result.stdout(), "stdout"); + case "response_json" -> jsonPointer(assertion, result.response(), "response"); + case "artifact_exists" -> artifactExists(assertion, result); + case "tool_call_count" -> toolCallCount(assertion, result); + default -> "unknown assertion type: " + type; + }; + } + + private String matchPattern(String patternText, String actual, String field) { + if (patternText == null) { + return "pattern is missing"; + } + if (actual == null) { + return field + " is empty"; + } + if (!Pattern.compile(patternText).matcher(actual).find()) { + return field + " does not match pattern: " + patternText; + } + return null; + } + + private String containsValue(String value, String actual, String field) { + if (value == null) { + return "value is missing"; + } + if (actual == null || !actual.contains(value)) { + return field + " does not contain: " + value; + } + return null; + } + + private String jsonPointer(AssertionSpec assertion, String actual, String field) { + String pointer = assertion.paramString("pointer"); + String expected = assertion.paramString("equals"); + if (pointer == null || expected == null) { + return "pointer or equals is missing"; + } + if (actual == null || actual.isBlank()) { + return field + " is empty"; + } + JsonNode root; + try { + root = OBJECT_MAPPER.readTree(actual); + } catch (Exception exception) { + return field + " is not valid JSON"; + } + JsonNode node = root.at(pointer); + if (node.isMissingNode()) { + return field + " has no value at pointer " + pointer; + } + String actualText = node.isTextual() ? node.asText() : node.toString(); + if (!expected.equals(actualText)) { + return "value at " + pointer + " is " + actualText + ", expected " + expected; + } + return null; + } + + private String artifactExists(AssertionSpec assertion, TaskResult result) { + String path = assertion.paramString("path"); + if (path == null) { + return "path is missing"; + } + byte[] content = result.artifacts().read(path).orElse(null); + if (content == null) { + return "artifact not found: " + path; + } + String expectedSha = assertion.paramString("sha256"); + if (expectedSha != null && !expectedSha.isBlank()) { + String actualSha = TaskResult.sha256Hex(content); + if (!expectedSha.toLowerCase().equals(actualSha)) { + return "artifact " + path + " sha256 is " + actualSha + ", expected " + expectedSha; + } + } + return null; + } + + private String toolCallCount(AssertionSpec assertion, TaskResult result) { + Integer min = assertion.paramInt("min"); + Integer max = assertion.paramInt("max"); + int actual = result.toolCallCount(); + if (min != null && actual < min) { + return "tool call count " + actual + " is below minimum " + min; + } + if (max != null && actual > max) { + return "tool call count " + actual + " exceeds maximum " + max; + } + return null; + } + + private boolean compiles(String pattern) { + try { + Pattern.compile(pattern); + return true; + } catch (PatternSyntaxException exception) { + return false; + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionFailure.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionFailure.java new file mode 100644 index 000000000..cf5bbd49f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionFailure.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import com.iflytek.skillhub.domain.authoring.spec.AssertionSpec; + +/** + * One assertion that did not hold, with a human-readable reason. + */ +public record AssertionFailure(AssertionSpec assertion, String reason) { + + public String describe() { + return "assertion '" + assertion.type() + "' failed: " + reason; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeEventSink.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeEventSink.java new file mode 100644 index 000000000..3db676471 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeEventSink.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +/** + * Callbacks a runtime adapter uses to stream execution progress into the persisted + * run event log. The orchestrator assigns sequence numbers and broadcasts to SSE + * subscribers; adapters only report what happened. + */ +public interface RuntimeEventSink { + + /** An agent-level message (model response, task narration). */ + void agentMessage(String taskName, String content); + + /** A tool invocation initiated by the runtime (script execution, HTTP tool call). */ + void toolCall(String taskName, String tool, String argumentsJson); + + /** The outcome of a tool invocation. */ + void toolResult(String taskName, String tool, String summaryJson); + + /** A raw log line from the runtime (stdout/stderr of a script, transport log). */ + void log(String taskName, String stream, String line); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeExecutionContext.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeExecutionContext.java new file mode 100644 index 000000000..41eed79c5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeExecutionContext.java @@ -0,0 +1,50 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * Everything a runtime adapter needs to execute one validation run: the isolated + * working directory containing the draft files, the adapter-specific configuration, + * the tool allowlist, the declared MCP servers, and the cancellation signal. + * + * @param runId validation run identifier, for logging + * @param workingDirectory isolated directory holding the draft's files (read-write) + * @param adapterConfig adapter-specific configuration map from the runtime binding + * @param toolAllowlist allowed tool identifiers; empty means unrestricted + * @param mcpServers MCP server declarations from the runtime binding (may be empty) + * @param cancellation cooperative cancel signal + */ +public record RuntimeExecutionContext( + Long runId, + Path workingDirectory, + Map adapterConfig, + List toolAllowlist, + List> mcpServers, + TaskCancellation cancellation +) { + + /** Convenience constructor for runtimes that do not consume MCP servers. */ + public RuntimeExecutionContext(Long runId, Path workingDirectory, Map adapterConfig, + List toolAllowlist, TaskCancellation cancellation) { + this(runId, workingDirectory, adapterConfig, toolAllowlist, List.of(), cancellation); + } + + public Map safeAdapterConfig() { + return adapterConfig == null ? Map.of() : adapterConfig; + } + + public List safeToolAllowlist() { + return toolAllowlist == null ? List.of() : List.copyOf(toolAllowlist); + } + + public List> safeMcpServers() { + return mcpServers == null ? List.of() : List.copyOf(mcpServers); + } + + public String configString(String key) { + Object value = safeAdapterConfig().get(key); + return value == null ? null : value.toString(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/SkillRuntimeAdapter.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/SkillRuntimeAdapter.java new file mode 100644 index 000000000..82b20c446 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/SkillRuntimeAdapter.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; + +/** + * SPI implemented by every agent runtime the authoring platform can bind to a draft. + * + *

Implementations live in the application layer (process execution, HTTP clients) + * and are selected by the runtime binding's agent type. The contract is deliberately + * minimal: execute one task, stream progress through the sink, return or throw. + * Assertion evaluation is handled by the orchestrator via {@link TaskResult}, not by + * adapters. + */ +public interface SkillRuntimeAdapter { + + /** The agent type identifier this adapter serves (see {@code AgentRuntimeType}). */ + String agentType(); + + /** Whether the adapter can execute the given task kind. */ + boolean supports(TaskType taskType); + + /** + * Whether the adapter is currently usable. Disabled adapters (missing server-side + * configuration, feature flags) are reported as RUNTIME_DISABLED findings instead of + * failed tasks when the orchestrator selects them. + */ + default boolean enabled() { + return true; + } + + /** + * Executes one validation task. Implementations must: + *

    + *
  • honor {@code context.cancellation()} while running;
  • + *
  • enforce the task timeout themselves;
  • + *
  • report progress through {@code sink};
  • + *
  • never modify draft state — the working directory is a disposable copy.
  • + *
+ * + * @return the raw task outcome for assertion evaluation + * @throws InterruptedException when cancelled mid-execution + */ + TaskResult execute(RuntimeExecutionContext context, ValidationTaskSpec task, + RuntimeEventSink sink) throws Exception; +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskCancellation.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskCancellation.java new file mode 100644 index 000000000..e4bef908e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskCancellation.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +/** + * Cooperative cancellation signal handed to runtime adapters. Adapters should poll + * {@link #isCancelRequested()} while executing (at least between output chunks) and + * abort as quickly as practical when it flips. + */ +@FunctionalInterface +public interface TaskCancellation { + + boolean isCancelRequested(); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskResult.java new file mode 100644 index 000000000..9fefcfd27 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskResult.java @@ -0,0 +1,56 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Optional; + +/** + * The raw outcome of one behavior validation task, consumed by the assertion + * evaluator. Script tasks populate exit code and streams; prompt tasks populate + * the agent response. The artifact resolver reads files produced inside the + * working directory (used by artifact_exists assertions). + */ +public record TaskResult( + Integer exitCode, + String stdout, + String stderr, + String response, + int toolCallCount, + ArtifactResolver artifacts +) { + + /** Reads a file produced by the task, relative to the working directory. */ + public interface ArtifactResolver { + Optional read(String relativePath); + } + + public static TaskResult ofScript(int exitCode, String stdout, String stderr, + int toolCallCount, ArtifactResolver artifacts) { + return new TaskResult(exitCode, stdout, stderr, null, toolCallCount, artifacts); + } + + public static TaskResult ofPrompt(String response, int toolCallCount, ArtifactResolver artifacts) { + return new TaskResult(null, null, null, response, toolCallCount, artifacts); + } + + public String sha256OfArtifact(String relativePath) { + return artifacts.read(relativePath) + .map(TaskResult::sha256Hex) + .orElse(null); + } + + public static String sha256Hex(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + public static String sha256Hex(String content) { + return sha256Hex(content.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/package-info.java new file mode 100644 index 000000000..c99d70d0b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/package-info.java @@ -0,0 +1,5 @@ +/** + * Runtime adapter SPI and behavior-validation support types shared by the domain and + * application layers. + */ +package com.iflytek.skillhub.domain.authoring.runtime; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/AuthoringSecurityPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/AuthoringSecurityPolicy.java new file mode 100644 index 000000000..42f70ca1d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/AuthoringSecurityPolicy.java @@ -0,0 +1,33 @@ +package com.iflytek.skillhub.domain.authoring.service; + +/** + * Security boundary for user-supplied runtime bindings, implemented by the + * application layer from {@code skillhub.authoring.*} configuration. The domain + * validator consults it at save time so that dangerous bindings are rejected + * before they can ever run. + * + *

Implementations must be conservative: an endpoint, transport, or + * environment reference that cannot be checked must be rejected, not allowed. + */ +public interface AuthoringSecurityPolicy { + + /** Where a user-supplied URL is about to be used, so flags can differ per surface. */ + enum EndpointUse { + MCP_SERVER, + LLM_API + } + + /** + * Decides whether the server may make an outbound HTTP(S) request to the + * given URL (SSRF guard). + * + * @return {@code null} when the URL is allowed, otherwise the rejection reason + */ + String endpointRejection(String url, EndpointUse use); + + /** Whether MCP servers may use the stdio transport (server-side process spawn). */ + boolean stdioTransportAllowed(); + + /** Whether a binding may reference the named server environment variable. */ + boolean envRefAllowed(String name); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidator.java new file mode 100644 index 000000000..72b75a0f3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidator.java @@ -0,0 +1,206 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.FixSuggestion; +import com.iflytek.skillhub.domain.authoring.FilePatch; +import com.iflytek.skillhub.domain.authoring.runtime.TaskResult; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; +import com.iflytek.skillhub.domain.skill.metadata.ComplianceMetadataService; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; + +/** + * The STRUCTURE validation layer: package layout, SKILL.md frontmatter, file limits, + * and content/extension consistency. Mirrors the checks of the publish-time + * {@code SkillPackageValidator} rule by rule, but reports structured findings with + * machine-applicable fix suggestions instead of opaque error strings. + * + *

Package limits and path rules come from the shared {@link SkillPackagePolicy}, + * so draft-time and publish-time rules cannot drift. + */ +@Service +public class DraftStructureValidator { + + private final SkillMetadataParser metadataParser; + private final ComplianceMetadataService complianceMetadataService; + private final SkillScaffoldGenerator scaffoldGenerator; + + public DraftStructureValidator(SkillMetadataParser metadataParser, + SkillScaffoldGenerator scaffoldGenerator) { + this.metadataParser = metadataParser; + this.complianceMetadataService = new ComplianceMetadataService(); + this.scaffoldGenerator = scaffoldGenerator; + } + + public record StructureReport(List findings, boolean skillMdPresent) { + + public boolean hasErrors() { + return findings.stream().anyMatch(finding -> finding.severity() + == com.iflytek.skillhub.domain.authoring.validation.FindingSeverity.ERROR); + } + } + + /** + * @param draftName draft name, used to build fix suggestions (e.g. missing SKILL.md) + * @param requirement original requirement text, reused in scaffold suggestions + */ + public StructureReport validate(String draftName, String requirement, List entries) { + List findings = new ArrayList<>(); + Set normalizedPaths = new HashSet<>(); + PackageEntry skillMd = null; + long totalSize = 0; + + for (PackageEntry entry : entries) { + String normalizedPath; + try { + normalizedPath = SkillPackagePolicy.normalizeEntryPath(entry.path()); + } catch (IllegalArgumentException exception) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "PATH_INVALID", + entry.path(), exception.getMessage(), null)); + continue; + } + normalizedPath = SkillPackagePolicy.canonicalizeSkillMdPath(normalizedPath); + + if (!normalizedPaths.add(normalizedPath)) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "PATH_DUPLICATE", + normalizedPath, "Duplicate file path: " + normalizedPath, null)); + } + if (!SkillPackagePolicy.hasAllowedExtension(normalizedPath)) { + findings.add(FindingDraft.warning(ValidationLayer.STRUCTURE, "EXTENSION_DISALLOWED", + normalizedPath, "Disallowed file extension: " + normalizedPath)); + } + String contentMismatch = SkillPackagePolicy.validateContentMatchesExtension( + normalizedPath, entry.content()); + if (contentMismatch != null) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "CONTENT_MISMATCH", + normalizedPath, contentMismatch, null)); + } + if (entry.size() > SkillPackagePolicy.MAX_SINGLE_FILE_SIZE) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "FILE_TOO_LARGE", + normalizedPath, "File too large: " + normalizedPath + " (" + + entry.size() + " bytes, max " + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE + ")", + null)); + } + if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) { + skillMd = entry; + } + totalSize += entry.size(); + } + + if (entries.size() > SkillPackagePolicy.MAX_FILE_COUNT) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "FILE_COUNT_EXCEEDED", null, + "Too many files: " + entries.size() + " (max " + SkillPackagePolicy.MAX_FILE_COUNT + ")", + null)); + } + if (totalSize > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "PACKAGE_TOO_LARGE", null, + "Package too large: " + totalSize + " bytes (max " + + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE + ")", null)); + } + + if (skillMd == null) { + String scaffold = scaffoldGenerator.generateSkillMd(draftName, requirement); + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "SKILL_MD_MISSING", null, + "Missing required file: SKILL.md at root", + new FixSuggestion("Create an initial SKILL.md scaffold", + List.of(new FilePatch(SkillPackagePolicy.SKILL_MD_PATH, null, null, scaffold))))); + return new StructureReport(findings, false); + } + + String skillMdContent = new String(skillMd.content(), StandardCharsets.UTF_8); + try { + var metadata = metadataParser.parse(skillMdContent); + findings.addAll(complianceFindings(metadata.frontmatter(), entries)); + } catch (LocalizedDomainException exception) { + findings.add(frontmatterFinding(exception, skillMdContent, draftName)); + } + + return new StructureReport(findings, true); + } + + private List complianceFindings(Map frontmatter, + List entries) { + List findings = new ArrayList<>(); + for (String problem : complianceMetadataService.validate(frontmatter, entries)) { + findings.add(FindingDraft.error(ValidationLayer.STRUCTURE, "COMPLIANCE_INVALID", + SkillPackagePolicy.SKILL_MD_PATH, problem, null)); + } + return findings; + } + + private FindingDraft frontmatterFinding(LocalizedDomainException exception, + String skillMdContent, String draftName) { + return switch (exception.messageCode()) { + case "error.skill.metadata.requiredField.missing" -> { + String field = String.valueOf(exception.messageArgs()[0]); + String replacement = insertFrontmatterField(skillMdContent, field, + suggestionValue(field, draftName)); + yield FindingDraft.error(ValidationLayer.STRUCTURE, "FRONTMATTER_FIELD_MISSING", + SkillPackagePolicy.SKILL_MD_PATH, + "SKILL.md frontmatter is missing required field: " + field, + new FixSuggestion("Add required field '" + field + "'", + List.of(new FilePatch(SkillPackagePolicy.SKILL_MD_PATH, + TaskResult.sha256Hex(skillMdContent), + skillMdContent, replacement)))); + } + case "error.skill.metadata.frontmatter.missingStart" -> FindingDraft.error( + ValidationLayer.STRUCTURE, "FRONTMATTER_MISSING_START", + SkillPackagePolicy.SKILL_MD_PATH, + "SKILL.md must start with a '---' frontmatter block", null); + case "error.skill.metadata.frontmatter.missingEnd" -> FindingDraft.error( + ValidationLayer.STRUCTURE, "FRONTMATTER_MISSING_END", + SkillPackagePolicy.SKILL_MD_PATH, + "SKILL.md frontmatter is missing the closing '---' marker", null); + case "error.skill.metadata.frontmatter.missingContent" -> FindingDraft.error( + ValidationLayer.STRUCTURE, "FRONTMATTER_EMPTY", + SkillPackagePolicy.SKILL_MD_PATH, "SKILL.md frontmatter is empty", null); + case "error.skill.metadata.yaml.notMap" -> FindingDraft.error( + ValidationLayer.STRUCTURE, "FRONTMATTER_NOT_MAP", + SkillPackagePolicy.SKILL_MD_PATH, + "SKILL.md frontmatter must be a YAML object", null); + case "error.skill.metadata.yaml.invalid" -> FindingDraft.error( + ValidationLayer.STRUCTURE, "FRONTMATTER_YAML_INVALID", + SkillPackagePolicy.SKILL_MD_PATH, + "SKILL.md frontmatter has invalid YAML: " + + (exception.messageArgs().length > 0 ? exception.messageArgs()[0] : ""), + null); + default -> FindingDraft.error(ValidationLayer.STRUCTURE, "FRONTMATTER_INVALID", + SkillPackagePolicy.SKILL_MD_PATH, exception.messageCode(), null); + }; + } + + private String suggestionValue(String field, String draftName) { + return switch (field) { + case "name" -> draftName == null ? "my-skill" : draftName; + case "description" -> "TODO: describe what this skill does"; + default -> "TODO"; + }; + } + + /** Inserts {@code field: value} as the first line inside the frontmatter block. */ + private String insertFrontmatterField(String content, String field, String value) { + String[] lines = content.split("\n", -1); + if (lines.length == 0 || !lines[0].trim().equals("---")) { + return "---\n" + field + ": " + value + "\n---\n" + content; + } + StringBuilder rebuilt = new StringBuilder(); + rebuilt.append(lines[0]).append('\n'); + rebuilt.append(field).append(": ").append(value).append('\n'); + for (int i = 1; i < lines.length; i++) { + rebuilt.append(lines[i]); + if (i < lines.length - 1) { + rebuilt.append('\n'); + } + } + return rebuilt.toString(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftSubmitService.java new file mode 100644 index 000000000..7d74b8f73 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftSubmitService.java @@ -0,0 +1,94 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import java.time.Clock; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Submits a validated draft into the existing SkillHub publish pipeline. The gate is + * strict: the draft's current revision must be bound to a validation run that + * succeeded with zero errors, so content can never be published without the exact + * bytes that were validated. Publishing reuses {@link SkillPublishService}, which + * re-validates the package, triggers the security scan, and opens the review flow. + */ +@Service +public class DraftSubmitService { + + private static final Logger log = LoggerFactory.getLogger(DraftSubmitService.class); + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + + public record SubmitOutcome(Long skillId, Long versionId, String slug, String version, + SkillPublishService.PublishResult publishResult) {} + + private final SkillDraftRepository draftRepository; + private final SkillDraftService draftService; + private final ValidationRunService runService; + private final SkillPublishService publishService; + private final Clock clock; + + public DraftSubmitService(SkillDraftRepository draftRepository, + SkillDraftService draftService, + ValidationRunService runService, + SkillPublishService publishService, + Clock clock) { + this.draftRepository = draftRepository; + this.draftService = draftService; + this.runService = runService; + this.publishService = publishService; + this.clock = clock; + } + + /** + * Publishes the draft's current revision as a new skill version. Warnings that the + * validation run already surfaced to the author are confirmed here; errors were a + * hard gate and cannot reach this point. + */ + @Transactional + public SubmitOutcome submit(Long draftId, String userId, SkillVisibility visibility, + Set platformRoles) { + SkillDraft draft = draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + assertOwner(draft, userId, platformRoles); + + if (runService.listRuns(draftId).stream().anyMatch(ValidationRun::isActive)) { + throw new DomainConflictException("error.authoring.submit.runActive", draftId); + } + ValidationRun passingRun = runService.requirePassingRunForRevision( + draftId, draft.getRevision()); + + String namespaceSlug = draftService.resolveNamespaceSlug(draft); + java.util.List entries = draftService.materializeEntries(draftId); + + SkillPublishService.PublishResult result = publishService.publishFromEntries( + namespaceSlug, entries, userId, visibility, platformRoles, true); + + draft.markSubmitted(result.skillId(), result.version().getId(), clock.instant()); + draftRepository.save(draft); + log.info("Draft {} revision {} submitted as skill {} version {} (validation run {})", + draftId, draft.getRevision(), result.skillId(), + result.version().getVersion(), passingRun.getId()); + return new SubmitOutcome(result.skillId(), result.version().getId(), result.slug(), + result.version().getVersion(), result); + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException( + "error.authoring.draft.forbidden", draft.getId()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/FindingFixService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/FindingFixService.java new file mode 100644 index 000000000..8bc9a2c57 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/FindingFixService.java @@ -0,0 +1,94 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import com.iflytek.skillhub.domain.authoring.validation.FindingStatus; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFindingRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Closes the fix loop for validation findings: an author previews a finding's + * machine-applicable suggestion, then confirms it. Applying the patch produces a new + * draft revision through {@link SkillDraftService#applyPatch} (which re-validates all + * sha256 guards), marks the finding APPLIED, and thereby invalidates the previous + * validation verdict — re-validation is always required afterwards. + */ +@Service +public class FindingFixService { + + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + + private final ValidationFindingRepository findingRepository; + private final ValidationRunRepository runRepository; + private final SkillDraftRepository draftRepository; + private final SkillDraftService draftService; + + public FindingFixService(ValidationFindingRepository findingRepository, + ValidationRunRepository runRepository, + SkillDraftRepository draftRepository, + SkillDraftService draftService) { + this.findingRepository = findingRepository; + this.runRepository = runRepository; + this.draftRepository = draftRepository; + this.draftService = draftService; + } + + public record FixOutcome(ValidationFinding finding, SkillDraft draft) {} + + /** + * Applies the finding's fix suggestion to the draft. The whole operation is atomic: + * either every patch lands and the finding is marked APPLIED, or nothing changes. + */ + @Transactional + public FixOutcome applyFix(Long findingId, String userId, Set platformRoles) { + OwnedFinding owned = loadOwnedFinding(findingId, userId, platformRoles); + ValidationFinding finding = owned.finding(); + if (!finding.hasSuggestion()) { + throw new DomainBadRequestException("error.authoring.finding.noSuggestion", findingId); + } + if (finding.getStatus() == FindingStatus.APPLIED) { + throw new DomainConflictException("error.authoring.finding.applied", findingId); + } + + SkillDraft draft = draftService.applyPatch( + owned.draftId(), userId, finding.getSuggestion().safePatches(), null, platformRoles); + finding.markApplied(draft.getRevision()); + findingRepository.save(finding); + return new FixOutcome(finding, draft); + } + + /** Dismisses a finding the author considers not applicable. */ + @Transactional + public ValidationFinding dismissFinding(Long findingId, String userId, Set platformRoles) { + ValidationFinding finding = loadOwnedFinding(findingId, userId, platformRoles).finding(); + finding.markDismissed(); + return findingRepository.save(finding); + } + + private record OwnedFinding(ValidationFinding finding, Long draftId) {} + + private OwnedFinding loadOwnedFinding(Long findingId, String userId, Set platformRoles) { + ValidationFinding finding = findingRepository.findById(findingId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.finding.notFound", findingId)); + ValidationRun run = runRepository.findById(finding.getRunId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.run.notFound", finding.getRunId())); + SkillDraft draft = draftRepository.findById(run.getDraftId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.draft.notFound", run.getDraftId())); + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return new OwnedFinding(finding, draft.getId()); + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingService.java new file mode 100644 index 000000000..90153578f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingService.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.AgentRuntimeType; +import com.iflytek.skillhub.domain.authoring.RuntimeBinding; +import com.iflytek.skillhub.domain.authoring.RuntimeBindingRepository; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.FindingSeverity; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Domain service for a draft's runtime binding: persists the agent type, adapter + * configuration, tool allowlist, and MCP server declarations. Validation findings are + * the responsibility of {@link RuntimeBindingValidator}; this service only rejects + * structurally invalid input at save time. + */ +@Service +public class RuntimeBindingService { + + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + + private final RuntimeBindingRepository bindingRepository; + private final SkillDraftRepository draftRepository; + private final RuntimeBindingValidator bindingValidator; + + public RuntimeBindingService(RuntimeBindingRepository bindingRepository, + SkillDraftRepository draftRepository, + RuntimeBindingValidator bindingValidator) { + this.bindingRepository = bindingRepository; + this.draftRepository = draftRepository; + this.bindingValidator = bindingValidator; + } + + /** + * Saves the runtime binding. Structurally invalid input (unknown agent type, + * embedded credentials) is rejected immediately; the full validation report is + * produced again as findings whenever a run starts. + */ + @Transactional + public RuntimeBinding saveBinding(Long draftId, String userId, String agentType, + Map adapterConfig, + List toolAllowlist, + List> mcpServers, + Set platformRoles) { + SkillDraft draft = draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + assertOwner(draft, userId, platformRoles); + + AgentRuntimeType type; + try { + type = AgentRuntimeType.fromIdentifier(agentType == null ? "" : agentType); + } catch (IllegalArgumentException exception) { + throw new DomainBadRequestException("error.authoring.binding.agentType.unknown", agentType); + } + + List findings = bindingValidator.validate( + type.identifier(), adapterConfig, toolAllowlist, mcpServers); + boolean hasErrors = findings.stream() + .anyMatch(finding -> finding.severity() == FindingSeverity.ERROR); + if (hasErrors) { + String first = findings.stream() + .filter(finding -> finding.severity() == FindingSeverity.ERROR) + .findFirst().map(FindingDraft::message).orElse("invalid binding"); + throw new DomainBadRequestException("error.authoring.binding.invalid", first); + } + + RuntimeBinding binding = bindingRepository.findByDraftId(draftId) + .orElseGet(() -> new RuntimeBinding(draftId, type)); + binding.update(type, adapterConfig, toolAllowlist, mcpServers, userId); + return bindingRepository.save(binding); + } + + @Transactional(readOnly = true) + public RuntimeBinding getBinding(Long draftId) { + return bindingRepository.findByDraftId(draftId) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.binding.notFound", draftId)); + } + + /** Optional accessor for callers that treat "not configured yet" as normal state. */ + @Transactional(readOnly = true) + public java.util.Optional findBinding(Long draftId) { + return bindingRepository.findByDraftId(draftId); + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingValidator.java new file mode 100644 index 000000000..7902f9879 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingValidator.java @@ -0,0 +1,236 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** + * The CONFIG validation layer: checks the runtime binding (agent type, adapter + * configuration, tool allowlist, MCP server declarations) plus the syntactic validity + * of validation task assertions. Rejects embedded plaintext credentials — bindings may + * only reference server-side credential names. Outbound endpoints, the stdio + * transport, and environment references are additionally gated by the + * {@link AuthoringSecurityPolicy} so unsafe bindings are rejected at save time. + */ +@Service +public class RuntimeBindingValidator { + + private static final Pattern TOOL_NAME = Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$"); + private static final Pattern MCP_NAME = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$"); + private static final Pattern CREDENTIAL_KEY = Pattern.compile( + "(?i).*(secret|password|passwd|api[_-]?key|token|credential).*"); + private static final Pattern ENV_REF_NAME = Pattern.compile("^[A-Z][A-Z0-9_]{0,63}$"); + private static final List TRANSPORTS = List.of("http", "sse", "stdio"); + private static final List INTERPRETERS = List.of("sh", "bash", "python3", "node"); + + private final AuthoringSecurityPolicy securityPolicy; + + public RuntimeBindingValidator(AuthoringSecurityPolicy securityPolicy) { + this.securityPolicy = securityPolicy; + } + + /** + * @param agentType agent type identifier (may be invalid — reported as finding) + * @param adapterConfig adapter-specific configuration map + * @param toolAllowlist tool identifiers (may be null = unrestricted) + * @param mcpServers MCP server declarations (may be null) + */ + public List validate(String agentType, Map adapterConfig, + List toolAllowlist, List> mcpServers) { + List findings = new ArrayList<>(); + String resolvedAgentType = agentType == null ? "" : agentType; + + switch (resolvedAgentType) { + case "local-script" -> validateLocalScriptConfig(adapterConfig, findings); + case "openai-compatible" -> validateOpenAiCompatibleConfig(adapterConfig, findings); + default -> findings.add(FindingDraft.error(ValidationLayer.CONFIG, "AGENT_TYPE_UNKNOWN", + "Unknown agent runtime type: " + resolvedAgentType + + " (supported: local-script, openai-compatible)")); + } + + if (toolAllowlist != null) { + for (String tool : toolAllowlist) { + if (tool == null || !TOOL_NAME.matcher(tool).matches()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "TOOL_ALLOWLIST_INVALID", + "Invalid tool allowlist entry: " + tool)); + } + } + } + + if (mcpServers != null) { + for (Map server : mcpServers) { + validateMcpServer(server, findings); + } + } + + scanForEmbeddedSecrets(agentType, adapterConfig, toolAllowlist, mcpServers, findings); + return findings; + } + + private void validateLocalScriptConfig(Map config, List findings) { + if (config == null) { + return; + } + Object interpreter = config.get("interpreter"); + if (interpreter != null && !INTERPRETERS.contains(interpreter.toString())) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "INTERPRETER_UNSUPPORTED", + "Unsupported interpreter: " + interpreter + " (supported: " + + String.join(", ", INTERPRETERS) + ")")); + } + Object envAllowlist = config.get("envAllowlist"); + if (envAllowlist instanceof List entries) { + for (Object entry : entries) { + if (entry == null || !ENV_REF_NAME.matcher(entry.toString()).matches()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "ENV_ALLOWLIST_INVALID", + "Invalid environment variable name: " + entry)); + } + } + } else if (envAllowlist != null) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "ENV_ALLOWLIST_INVALID", + "envAllowlist must be a list of variable names")); + } + } + + private void validateOpenAiCompatibleConfig(Map config, List findings) { + if (config == null) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_CONFIG_MISSING", + "openai-compatible runtime requires a config object with endpoint and model")); + return; + } + Object endpoint = config.get("endpoint"); + if (endpoint == null || endpoint.toString().isBlank()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_ENDPOINT_MISSING", + "openai-compatible runtime requires an 'endpoint' URL")); + } else if (!isHttpUrl(endpoint.toString())) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_ENDPOINT_INVALID", + "endpoint must be an http(s) URL: " + endpoint)); + } else { + // these requests carry the server-side API key; the SSRF guard must + // approve the destination before any credential can leave the host + String rejection = securityPolicy.endpointRejection( + endpoint.toString(), AuthoringSecurityPolicy.EndpointUse.LLM_API); + if (rejection != null) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_ENDPOINT_BLOCKED", + "openai-compatible endpoint is not allowed: " + rejection)); + } + } + Object model = config.get("model"); + if (model == null || model.toString().isBlank()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_MODEL_MISSING", + "openai-compatible runtime requires a 'model' name")); + } + Object temperature = config.get("temperature"); + if (temperature instanceof Number number + && (number.doubleValue() < 0 || number.doubleValue() > 2)) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_TEMPERATURE_INVALID", + "temperature must be between 0 and 2")); + } + // the API key is resolved from server configuration, never from the binding + if (config.containsKey("apiKey") || config.containsKey("api_key")) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "CREDENTIAL_EMBEDDED", + "apiKey must not be stored in the binding; configure it server-side")); + } + } + + private void validateMcpServer(Map server, List findings) { + Object name = server.get("name"); + if (name == null || !MCP_NAME.matcher(name.toString()).matches()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_NAME_INVALID", + "MCP server name must be lowercase alphanumerics/hyphens: " + name)); + } + Object transport = server.get("transport"); + if (transport == null || !TRANSPORTS.contains(transport.toString())) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_TRANSPORT_INVALID", + "MCP server " + name + " has unknown transport: " + transport + + " (supported: " + String.join(", ", TRANSPORTS) + ")")); + } else if ("http".equals(transport.toString()) || "sse".equals(transport.toString())) { + Object endpoint = server.get("endpoint"); + if (endpoint == null || !isHttpUrl(endpoint.toString())) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_ENDPOINT_INVALID", + "MCP server " + name + " requires an http(s) endpoint")); + } else { + String rejection = securityPolicy.endpointRejection( + endpoint.toString(), AuthoringSecurityPolicy.EndpointUse.MCP_SERVER); + if (rejection != null) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_ENDPOINT_BLOCKED", + "MCP server " + name + " endpoint is not allowed: " + rejection)); + } + } + } else { + if (!securityPolicy.stdioTransportAllowed()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_STDIO_DISABLED", + "MCP server " + name + " uses the stdio transport, which is disabled" + + " (skillhub.authoring.mcp.stdio-enabled=false); use http/sse instead")); + } + Object command = server.get("command"); + if (command == null || command.toString().isBlank()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_COMMAND_MISSING", + "MCP server " + name + " (stdio) requires a command")); + } + } + Object toolFilters = server.get("toolFilters"); + if (toolFilters instanceof List filters) { + for (Object filter : filters) { + if (filter == null || !TOOL_NAME.matcher(filter.toString()).matches()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_TOOL_FILTER_INVALID", + "MCP server " + name + " has an invalid tool filter: " + filter)); + } + } + } + Object envRefs = server.get("envRefs"); + if (envRefs instanceof List refs) { + for (Object ref : refs) { + if (ref == null || !ENV_REF_NAME.matcher(ref.toString()).matches()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_ENV_REF_INVALID", + "MCP server " + name + " has an invalid env reference: " + ref)); + } else if (!securityPolicy.envRefAllowed(ref.toString())) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_ENV_REF_NOT_ALLOWED", + "MCP server " + name + " may not reference environment variable " + + ref + " (not in skillhub.authoring.mcp.env-allowlist)")); + } + } + } + } + + /** Rejects secret-looking keys anywhere in the binding payload. */ + private void scanForEmbeddedSecrets(String agentType, Map adapterConfig, + List toolAllowlist, + List> mcpServers, + List findings) { + if (adapterConfig != null) { + scanMap(adapterConfig, findings); + } + if (mcpServers != null) { + for (Map server : mcpServers) { + scanMap(server, findings); + } + } + } + + private void scanMap(Map map, List findings) { + for (Map.Entry entry : map.entrySet()) { + if (CREDENTIAL_KEY.matcher(entry.getKey()).matches() + && entry.getValue() != null && !entry.getValue().toString().isBlank()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "CREDENTIAL_EMBEDDED", + "Binding must not embed credentials in field '" + entry.getKey() + + "'; reference server-side credentials instead")); + } + } + } + + private boolean isHttpUrl(String value) { + try { + URI uri = URI.create(value); + String scheme = uri.getScheme(); + return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && uri.getHost() != null; + } catch (IllegalArgumentException exception) { + return false; + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillDraftService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillDraftService.java new file mode 100644 index 000000000..174748591 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillDraftService.java @@ -0,0 +1,463 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.authoring.DraftFile; +import com.iflytek.skillhub.domain.authoring.FilePatch; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import com.iflytek.skillhub.domain.authoring.DraftFileRepository; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Domain service for skill authoring drafts: creation with a scaffold, content-addressed + * file storage, revision and digest bookkeeping, patch application, and materialization + * into package entries for validation and submission. + * + *

Revisions advance only when content actually changes; a no-op save keeps the + * revision (and therefore a prior validation verdict) intact. Every content change + * recomputes the digest, which binds validation runs to exact draft content. + */ +@Service +public class SkillDraftService { + + private static final Logger log = LoggerFactory.getLogger(SkillDraftService.class); + private static final int MAX_NAME_LENGTH = 128; + private static final int MAX_REQUIREMENT_LENGTH = 8_000; + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + + public record SaveFileOutcome(SkillDraft draft, DraftFile file, boolean created, boolean revisionAdvanced) {} + + private final SkillDraftRepository draftRepository; + private final DraftFileRepository fileRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final ObjectStorageService objectStorageService; + private final SkillScaffoldGenerator scaffoldGenerator; + private final ObjectMapper objectMapper; + + public SkillDraftService(SkillDraftRepository draftRepository, + DraftFileRepository fileRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + ObjectStorageService objectStorageService, + SkillScaffoldGenerator scaffoldGenerator, + ObjectMapper objectMapper) { + this.draftRepository = draftRepository; + this.fileRepository = fileRepository; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.objectStorageService = objectStorageService; + this.scaffoldGenerator = scaffoldGenerator; + this.objectMapper = objectMapper; + } + + // ---------------------------------------------------------------- draft lifecycle + + /** + * Creates a draft in the target namespace and seeds it with a SKILL.md scaffold + * derived from the requirement description. + */ + @Transactional + public SkillDraft createDraft(String namespaceSlug, String ownerId, String name, + String requirement, Set platformRoles) { + validateName(name); + if (requirement != null && requirement.length() > MAX_REQUIREMENT_LENGTH) { + throw new DomainBadRequestException("error.authoring.draft.requirement.tooLong", + MAX_REQUIREMENT_LENGTH); + } + + Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.namespace.notFound", namespaceSlug)); + assertNamespaceWritable(namespace); + assertNamespaceMember(namespace, ownerId, platformRoles); + + if (draftRepository.findByOwnerIdAndNameIgnoreCase(ownerId, name).isPresent()) { + throw new DomainConflictException("error.authoring.draft.name.duplicate", name); + } + + String scaffold = scaffoldGenerator.generateSkillMd(name, requirement); + SkillDraft draft = draftRepository.save( + new SkillDraft(namespace.getId(), ownerId, name, requirement, "pending")); + DraftFile scaffoldFile = storeFile(draft.getId(), SkillPackagePolicy.SKILL_MD_PATH, + scaffold.getBytes(StandardCharsets.UTF_8), "text/markdown"); + fileRepository.save(scaffoldFile); + // the scaffold must be readable immediately: editor, structure validation, + // and submit all read it back from object storage + putContent(scaffoldFile.getStorageKey(), scaffold.getBytes(StandardCharsets.UTF_8), + "text/markdown"); + draft.applyContentChange(1, computeDigest(draft.getId())); + return draftRepository.save(draft); + } + + @Transactional(readOnly = true) + public List listDrafts(String ownerId) { + return draftRepository.findByOwnerIdOrderByUpdatedAtDesc(ownerId); + } + + @Transactional(readOnly = true) + public SkillDraft getOwnedDraft(Long draftId, String userId, Set platformRoles) { + SkillDraft draft = draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + assertOwner(draft, userId, platformRoles); + return draft; + } + + /** Trusted accessor for internal executors (validation pipeline, submit flow). */ + @Transactional(readOnly = true) + public SkillDraft getDraft(Long draftId) { + return draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + } + + @Transactional + public void deleteDraft(Long draftId, String userId, Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + List storageKeys = fileRepository.findByDraftIdOrderByFilePath(draftId).stream() + .map(DraftFile::getStorageKey) + .toList(); + if (!storageKeys.isEmpty()) { + try { + objectStorageService.deleteObjects(storageKeys); + } catch (Exception exception) { + // storage garbage is not worth blocking a draft deletion; keys are + // namespaced under drafts/{draftId} and become unreachable + log.warn("Failed to clean up draft storage for draft {}: {}", + draftId, exception.getMessage()); + } + } + draftRepository.delete(draft); + } + + // ---------------------------------------------------------------- file editing + + /** + * Saves one file. When {@code expectedRevision} is provided it must match the current + * revision, otherwise the save is rejected with a conflict (optimistic concurrency). + */ + @Transactional + public SaveFileOutcome saveFile(Long draftId, String userId, String rawPath, byte[] content, + String contentType, Integer expectedRevision, + Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + assertRevisionMatches(draft, expectedRevision); + String path = normalizeAndCheckPath(rawPath); + + if (content == null) { + throw new DomainBadRequestException("error.authoring.file.content.required"); + } + if (content.length > SkillPackagePolicy.MAX_SINGLE_FILE_SIZE) { + throw new DomainBadRequestException("error.authoring.file.tooLarge", path, + SkillPackagePolicy.MAX_SINGLE_FILE_SIZE); + } + String mismatch = SkillPackagePolicy.validateContentMatchesExtension(path, content); + if (mismatch != null) { + throw new DomainBadRequestException("error.authoring.file.contentMismatch", path, mismatch); + } + + Optional existing = fileRepository.findByDraftIdAndFilePath(draftId, path); + if (existing.isEmpty() && fileRepository.countByDraftId(draftId) >= SkillPackagePolicy.MAX_FILE_COUNT) { + throw new DomainBadRequestException("error.authoring.file.countExceeded", + SkillPackagePolicy.MAX_FILE_COUNT); + } + long currentTotal = fileRepository.sumSizeByDraftId(draftId) + - existing.map(DraftFile::getSize).orElse(0L); + if (currentTotal + content.length > SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE) { + throw new DomainBadRequestException("error.authoring.package.tooLarge", + SkillPackagePolicy.MAX_TOTAL_PACKAGE_SIZE); + } + + DraftFile file = existing + .map(current -> { + current.updateContent(sha256Hex(content), (long) content.length, + resolveContentType(path, contentType), storageKey(draftId, content)); + return current; + }) + .orElseGet(() -> storeFile(draftId, path, content, resolveContentType(path, contentType))); + file = fileRepository.save(file); + // write content after resolving the key so the same digest always lands in the same object + putContent(storageKey(draftId, content), content, resolveContentType(path, contentType)); + + boolean revisionAdvanced = advanceRevisionIfContentChanged(draft); + return new SaveFileOutcome(draft, file, existing.isEmpty(), revisionAdvanced); + } + + @Transactional + public void deleteFile(Long draftId, String userId, String rawPath, Integer expectedRevision, + Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + assertRevisionMatches(draft, expectedRevision); + String path = normalizeAndCheckPath(rawPath); + DraftFile file = fileRepository.findByDraftIdAndFilePath(draftId, path) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.file.notFound", path)); + fileRepository.delete(file); + advanceRevisionIfContentChanged(draft); + } + + @Transactional(readOnly = true) + public List listFiles(Long draftId) { + return fileRepository.findByDraftIdOrderByFilePath(draftId); + } + + @Transactional(readOnly = true) + public FileContent readFile(Long draftId, String userId, String rawPath, + Set platformRoles) { + getOwnedDraft(draftId, userId, platformRoles); + String path; + try { + path = SkillPackagePolicy.canonicalizeSkillMdPath( + SkillPackagePolicy.normalizeEntryPath(rawPath)); + } catch (IllegalArgumentException exception) { + throw new DomainBadRequestException("error.authoring.file.path.invalid", rawPath); + } + DraftFile file = fileRepository.findByDraftIdAndFilePath(draftId, path) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.file.notFound", path)); + byte[] content; + try (InputStream input = objectStorageService.getObject(file.getStorageKey())) { + content = input.readAllBytes(); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read draft file: " + path, exception); + } + return new FileContent(file, content); + } + + public record FileContent(DraftFile file, byte[] content) { + public String asText() { + return new String(content, StandardCharsets.UTF_8); + } + } + + // ---------------------------------------------------------------- patches + + /** + * Applies a confirmed set of file patches atomically: every patch must target the + * exact current content (sha256 match), and the whole batch produces exactly one + * new draft revision. + */ + @Transactional + public SkillDraft applyPatch(Long draftId, String userId, List patches, + Integer expectedRevision, Set platformRoles) { + SkillDraft draft = getOwnedDraft(draftId, userId, platformRoles); + assertRevisionMatches(draft, expectedRevision); + if (patches == null || patches.isEmpty()) { + throw new DomainBadRequestException("error.authoring.patch.empty"); + } + + for (FilePatch patch : patches) { + String path = normalizeAndCheckPath(patch.filePath()); + byte[] content = patch.newValue().getBytes(StandardCharsets.UTF_8); + String contentType = resolveContentType(path, null); + Optional existing = fileRepository.findByDraftIdAndFilePath(draftId, path); + if (patch.isCreation()) { + if (existing.isPresent()) { + throw new DomainConflictException("error.authoring.patch.fileExists", path); + } + fileRepository.save(storeFile(draftId, path, content, contentType)); + } else { + DraftFile current = existing.orElseThrow(() -> + new DomainConflictException("error.authoring.patch.fileMissing", path)); + String currentSha = current.getSha256(); + if (patch.oldSha256() == null || !patch.oldSha256().equalsIgnoreCase(currentSha)) { + throw new DomainConflictException("error.authoring.patch.stale", path); + } + current.updateContent(sha256Hex(content), (long) content.length, + contentType, storageKey(draftId, content)); + fileRepository.save(current); + } + putContent(storageKey(draftId, content), content, contentType); + } + + boolean advanced = advanceRevisionIfContentChanged(draft); + if (!advanced) { + // a patch that does not change the digest is a programming error upstream + throw new DomainBadRequestException("error.authoring.patch.noChange"); + } + return draft; + } + + // ---------------------------------------------------------------- materialization + + /** + * Builds streaming package entries from the draft's current files. Used by the + * validation orchestrator (snapshot) and the submit flow (publish input). + */ + @Transactional(readOnly = true) + public List materializeEntries(Long draftId) { + List entries = new ArrayList<>(); + for (DraftFile file : fileRepository.findByDraftIdOrderByFilePath(draftId)) { + entries.add(PackageEntry.streaming( + file.getFilePath(), + file.getSize(), + file.getContentType(), + () -> objectStorageService.getObject(file.getStorageKey()))); + } + return entries; + } + + @Transactional(readOnly = true) + public String computeDigest(Long draftId) { + List files = new ArrayList<>(fileRepository.findByDraftIdOrderByFilePath(draftId)); + files.sort(Comparator.comparing(DraftFile::getFilePath)); + StringBuilder manifest = new StringBuilder(); + for (DraftFile file : files) { + manifest.append(file.getFilePath()).append('\u0000') + .append(file.getSha256()).append('\n'); + } + return sha256Hex(manifest.toString()); + } + + @Transactional(readOnly = true) + public String resolveNamespaceSlug(SkillDraft draft) { + return namespaceRepository.findById(draft.getNamespaceId()) + .map(Namespace::getSlug) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.namespace.notFound", draft.getNamespaceId())); + } + + // ---------------------------------------------------------------- internals + + private DraftFile storeFile(Long draftId, String path, byte[] content, String contentType) { + return new DraftFile(draftId, path, sha256Hex(content), (long) content.length, + contentType, storageKey(draftId, content)); + } + + private void putContent(String key, byte[] content, String contentType) { + if (!objectStorageService.exists(key)) { + objectStorageService.putObject(key, new ByteArrayInputStream(content), + content.length, contentType); + } + } + + private boolean advanceRevisionIfContentChanged(SkillDraft draft) { + String newDigest = computeDigest(draft.getId()); + if (newDigest.equals(draft.getContentDigest())) { + return false; + } + draft.applyContentChange(draft.getRevision() + 1, newDigest); + draftRepository.save(draft); + return true; + } + + private String storageKey(Long draftId, byte[] content) { + return "drafts/" + draftId + "/" + sha256Hex(content); + } + + private String resolveContentType(String path, String provided) { + if (provided != null && !provided.isBlank()) { + return provided; + } + String lower = path.toLowerCase(Locale.ROOT); + if (lower.endsWith(".md")) { + return "text/markdown"; + } + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { + return "application/yaml"; + } + if (lower.endsWith(".json")) { + return "application/json"; + } + if (lower.endsWith(".py")) { + return "text/x-python"; + } + if (lower.endsWith(".sh")) { + return "text/x-shellscript"; + } + return "application/octet-stream"; + } + + private String normalizeAndCheckPath(String rawPath) { + String normalized; + try { + normalized = SkillPackagePolicy.canonicalizeSkillMdPath( + SkillPackagePolicy.normalizeEntryPath(rawPath)); + } catch (IllegalArgumentException exception) { + throw new DomainBadRequestException("error.authoring.file.path.invalid", rawPath); + } + if (!SkillPackagePolicy.hasAllowedExtension(normalized)) { + throw new DomainBadRequestException("error.authoring.file.extension.disallowed", normalized); + } + return normalized; + } + + private void assertRevisionMatches(SkillDraft draft, Integer expectedRevision) { + if (expectedRevision != null && !expectedRevision.equals(draft.getRevision())) { + throw new DomainConflictException("error.authoring.draft.revision.conflict", + expectedRevision, draft.getRevision()); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new DomainBadRequestException("error.authoring.draft.name.required"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new DomainBadRequestException("error.authoring.draft.name.tooLong", MAX_NAME_LENGTH); + } + } + + private void assertNamespaceWritable(Namespace namespace) { + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + throw new DomainForbiddenException("error.authoring.namespace.frozen", namespace.getSlug()); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainForbiddenException("error.authoring.namespace.archived", namespace.getSlug()); + } + } + + private void assertNamespaceMember(Namespace namespace, String userId, Set platformRoles) { + if (platformRoles != null && platformRoles.contains(SUPER_ADMIN)) { + return; + } + if (namespaceMemberRepository + .findByNamespaceIdAndUserId(namespace.getId(), userId).isEmpty()) { + throw new DomainForbiddenException("error.authoring.draft.notMember", namespace.getSlug()); + } + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } + + public static String sha256Hex(byte[] content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(content)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + public static String sha256Hex(String content) { + return sha256Hex(content.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGenerator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGenerator.java new file mode 100644 index 000000000..d0316acfe --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGenerator.java @@ -0,0 +1,78 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.namespace.SlugValidator; +import org.springframework.stereotype.Service; + +/** + * Generates the initial SKILL.md scaffold for a new draft from the author's name and + * requirement description. Template-based by design: scaffolds are a starting point + * the author edits and validates; model-assisted generation remains an optional + * extension layered on top of this deterministic baseline. + */ +@Service +public class SkillScaffoldGenerator { + + /** + * Builds the scaffold SKILL.md content. + * + * @param draftName the draft name chosen by the author + * @param requirement the natural-language requirement description + */ + public String generateSkillMd(String draftName, String requirement) { + String skillName = safeName(draftName); + String overview = requirement == null || requirement.isBlank() + ? "Describe what this skill does and when an agent should use it." + : requirement.strip(); + return """ + --- + name: %s + description: %s + --- + + # %s + + ## Overview + + %s + + ## Usage + + Describe step by step how an agent should apply this skill. + + ## Resources + + - `references/` — background material the skill can cite + - `scripts/` — executable helpers invoked during task execution + """.formatted(skillName, singleLine(overview), skillName, overview); + } + + /** Builds a starter validation.yaml with one script task template. */ + public String generateValidationYaml() { + return """ + version: 1 + tasks: + - name: smoke + description: Replace with a real check for this skill + type: script + script: scripts/check.sh + args: [] + timeoutMs: 30000 + assertions: + - type: exit_code + equals: 0 + """; + } + + private String safeName(String draftName) { + try { + String slug = SlugValidator.slugify(draftName); + return slug == null || slug.isBlank() ? "my-skill" : slug; + } catch (Exception exception) { + return "my-skill"; + } + } + + private String singleLine(String text) { + return text.replaceAll("\\s+", " ").trim(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/ValidationRunService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/ValidationRunService.java new file mode 100644 index 000000000..f2e21c963 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/ValidationRunService.java @@ -0,0 +1,256 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventType; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFindingRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.time.Clock; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * Domain service for validation run lifecycle: starting runs bound to a frozen draft + * revision, appending ordered events, recording findings, and settling runs into + * terminal states. Events and findings commit in their own transactions so partial + * progress is always visible to SSE subscribers and polling clients. + */ +@Service +public class ValidationRunService { + + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + private static final List ACTIVE_STATUSES = List.of( + ValidationRunStatus.QUEUED, ValidationRunStatus.PREPARING, ValidationRunStatus.RUNNING); + + private final ValidationRunRepository runRepository; + private final ValidationEventRepository eventRepository; + private final ValidationFindingRepository findingRepository; + private final SkillDraftRepository draftRepository; + private final Clock clock; + + public ValidationRunService(ValidationRunRepository runRepository, + ValidationEventRepository eventRepository, + ValidationFindingRepository findingRepository, + SkillDraftRepository draftRepository, + Clock clock) { + this.runRepository = runRepository; + this.eventRepository = eventRepository; + this.findingRepository = findingRepository; + this.draftRepository = draftRepository; + this.clock = clock; + } + + // ---------------------------------------------------------------- lifecycle + + /** + * Creates a QUEUED run bound to the draft's current revision. Only one active run + * per draft is allowed. + */ + @Transactional + public ValidationRun startRun(Long draftId, String userId, Set platformRoles) { + SkillDraft draft = draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + assertOwner(draft, userId, platformRoles); + if (runRepository.findByDraftIdOrderByCreatedAtDesc(draftId).stream() + .anyMatch(ValidationRun::isActive)) { + throw new DomainConflictException("error.authoring.run.activeExists", draftId); + } + return runRepository.save( + new ValidationRun(draftId, draft.getRevision(), userId)); + } + + /** + * Transitions a QUEUED run through PREPARING to RUNNING under a row lock, so + * cancellation and execution cannot interleave. Returns the refreshed run. + */ + @Transactional + public ValidationRun claimForExecution(Long runId) { + ValidationRun run = runRepository.findByIdForUpdate(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + if (run.getStatus() == ValidationRunStatus.CANCELLED) { + throw new DomainConflictException("error.authoring.run.cancelled", runId); + } + if (run.getStatus() != ValidationRunStatus.QUEUED) { + throw new DomainConflictException("error.authoring.run.notQueued", runId); + } + if (run.isCancelRequested()) { + run.finish(ValidationRunStatus.CANCELLED, 0, 0, null, clock.instant()); + return runRepository.save(run); + } + run.markPreparing(); + run.markRunning(clock.instant()); + return runRepository.save(run); + } + + /** + * Idempotent cancel request. Sets the cooperative flag and settles QUEUED runs + * immediately; RUNNING/PREPARING runs are finished by their executor thread when + * it observes the flag (or by the maintenance sweep). + */ + @Transactional + public ValidationRun requestCancel(Long runId, String userId, Set platformRoles) { + ValidationRun run = runRepository.findByIdForUpdate(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + SkillDraft draft = draftRepository.findById(run.getDraftId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.draft.notFound", run.getDraftId())); + assertOwner(draft, userId, platformRoles); + + if (!run.isActive()) { + return run; // already terminal: cancel is idempotent + } + run.requestCancel(); + if (run.getStatus() == ValidationRunStatus.QUEUED) { + run.finish(ValidationRunStatus.CANCELLED, 0, 0, + Map.of("reason", "cancelled before start"), clock.instant()); + } + return runRepository.save(run); + } + + /** + * Settles a run into a terminal status if it is still active; returns empty when the + * run was already terminal (idempotent settle — the first terminal state wins). When + * the run succeeds with zero errors and the draft has not moved on, the draft is + * marked validated for this revision. + */ + @Transactional + public Optional settleIfActive(Long runId, ValidationRunStatus terminalStatus, + int errorCount, int warningCount, + Map summary) { + ValidationRun run = runRepository.findByIdForUpdate(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + if (run.getStatus().isTerminal()) { + return Optional.empty(); + } + run.finish(terminalStatus, errorCount, warningCount, summary, clock.instant()); + ValidationRun settled = runRepository.save(run); + + if (terminalStatus == ValidationRunStatus.SUCCEEDED && errorCount == 0) { + draftRepository.findById(settled.getDraftId()).ifPresent(draft -> { + if (draft.getRevision().equals(settled.getDraftRevision())) { + draft.markValidated(settled.getDraftRevision(), settled.getId()); + draftRepository.save(draft); + } + }); + } + return Optional.of(settled); + } + + // ---------------------------------------------------------------- events & findings + + /** Appends one event with the next sequence number; commits independently. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ValidationEvent appendEvent(Long runId, ValidationEventType type, String phase, + Map payload) { + int nextSeq = eventRepository.findMaxSeqByRunId(runId).map(seq -> seq + 1).orElse(1); + return eventRepository.save( + new ValidationEvent(runId, nextSeq, type, phase, payload)); + } + + /** Records one finding and returns it; commits independently. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ValidationFinding recordFinding(Long runId, FindingDraft draft) { + return findingRepository.save(new ValidationFinding( + runId, draft.layer(), draft.ruleCode(), draft.severity(), + draft.filePath(), draft.location(), draft.message(), draft.suggestion())); + } + + // ---------------------------------------------------------------- queries + + @Transactional(readOnly = true) + public ValidationRun getOwnedRun(Long runId, String userId, Set platformRoles) { + ValidationRun run = runRepository.findById(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + SkillDraft draft = draftRepository.findById(run.getDraftId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.draft.notFound", run.getDraftId())); + assertOwner(draft, userId, platformRoles); + return run; + } + + /** Trusted accessor for internal executors (validation pipeline, maintenance sweep). */ + @Transactional(readOnly = true) + public ValidationRun getRun(Long runId) { + return runRepository.findById(runId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.run.notFound", runId)); + } + + @Transactional(readOnly = true) + public List listRuns(Long draftId) { + return runRepository.findByDraftIdOrderByCreatedAtDesc(draftId); + } + + @Transactional(readOnly = true) + public List listEvents(Long runId, Integer afterSeq) { + if (afterSeq == null) { + return eventRepository.findByRunIdOrderBySeqAsc(runId); + } + return eventRepository.findByRunIdAndSeqGreaterThanOrderBySeqAsc(runId, afterSeq); + } + + @Transactional(readOnly = true) + public List listFindings(Long runId) { + List findings = findingRepository.findByRunId(runId); + findings.sort(Comparator + .comparing(ValidationFinding::getSeverity) + .thenComparing(ValidationFinding::getId)); + return findings; + } + + @Transactional(readOnly = true) + public ValidationFinding getFinding(Long findingId) { + return findingRepository.findById(findingId) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.finding.notFound", findingId)); + } + + @Transactional(readOnly = true) + public List findActiveRuns() { + return runRepository.findByStatusIn(ACTIVE_STATUSES); + } + + @Transactional(readOnly = true) + public List findActiveRunsCreatedBefore(Instant cutoff) { + return runRepository.findByStatusInAndCreatedAtBefore(ACTIVE_STATUSES, cutoff); + } + + /** + * The submission gate: the latest run bound to the draft's current revision must + * have succeeded with zero errors. + */ + @Transactional(readOnly = true) + public ValidationRun requirePassingRunForRevision(Long draftId, Integer revision) { + ValidationRun run = runRepository + .findFirstByDraftIdAndDraftRevisionOrderByCreatedAtDesc(draftId, revision) + .orElseThrow(() -> new DomainConflictException( + "error.authoring.submit.notValidated", revision)); + if (run.getStatus() != ValidationRunStatus.SUCCEEDED || run.getErrorCount() > 0) { + throw new DomainConflictException("error.authoring.submit.notValidated", revision); + } + return run; + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/AssertionSpec.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/AssertionSpec.java new file mode 100644 index 000000000..43e6dd755 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/AssertionSpec.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +import java.util.Map; + +/** + * One assertion inside a validation task. Assertions are deliberately loosely typed: + * {@code type} selects the check and {@code params} carries its typed parameters, + * which the evaluator validates and coerces. Unknown types or missing parameters are + * reported as configuration-layer findings before behavior execution starts. + */ +public record AssertionSpec(String type, Map params) { + + public Map safeParams() { + return params == null ? Map.of() : params; + } + + public String paramString(String key) { + Object value = safeParams().get(key); + return value == null ? null : value.toString(); + } + + public Integer paramInt(String key) { + Object value = safeParams().get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/TaskType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/TaskType.java new file mode 100644 index 000000000..58fc6ff96 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/TaskType.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +/** + * Task kinds supported by behavior validation. + */ +public enum TaskType { + + /** Executes a script shipped with the skill in the isolated working directory. */ + SCRIPT, + + /** Sends a prompt to the bound agent runtime with the skill as context. */ + PROMPT +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpec.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpec.java new file mode 100644 index 000000000..b1bc5b34d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpec.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +import java.util.List; +import java.util.Optional; + +/** + * Parsed content of a draft's {@code validation.yaml}: the ordered list of behavior + * validation tasks executed by the BEHAVIOR layer. A draft without the file simply + * runs zero behavior tasks. + */ +public record ValidationSpec(int version, List tasks) { + + public static final String FILE_PATH = "validation.yaml"; + public static final int CURRENT_VERSION = 1; + public static final int DEFAULT_TASK_TIMEOUT_MS = 60_000; + + public static ValidationSpec empty() { + return new ValidationSpec(CURRENT_VERSION, List.of()); + } + + public List safeTasks() { + return tasks == null ? List.of() : List.copyOf(tasks); + } + + public Optional findTask(String name) { + return safeTasks().stream().filter(task -> task.name().equals(name)).findFirst(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParser.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParser.java new file mode 100644 index 000000000..60695ab12 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParser.java @@ -0,0 +1,211 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** + * Parses a draft's {@code validation.yaml} into {@link ValidationSpec}. + * + *

Follows the same defensive YAML loading rules as {@code SkillMetadataParser}: + * SnakeYAML safe constructor, bounded aliases, nesting depth, and code points. + * Parse problems are reported as structured spec errors (rule code + message) instead + * of exceptions so the configuration validation layer can surface them as findings. + */ +public class ValidationSpecParser { + + private static final int MAX_YAML_ALIASES = 20; + private static final int MAX_YAML_NESTING_DEPTH = 20; + private static final int MAX_YAML_CODE_POINTS = 100_000; + private static final int MAX_TASKS = 50; + private static final int MAX_TASK_TIMEOUT_MS = 600_000; + + /** A structured parse or semantic error detected while reading the spec. */ + public record SpecError(String ruleCode, String message) {} + + public record ParseOutcome(ValidationSpec spec, List errors) { + + public boolean isValid() { + return errors.isEmpty(); + } + } + + public ParseOutcome parse(String content) { + if (content == null || content.isBlank()) { + return failure("SPEC_EMPTY", "validation.yaml is empty"); + } + Object parsed; + try { + LoaderOptions loaderOptions = new LoaderOptions(); + loaderOptions.setAllowDuplicateKeys(false); + loaderOptions.setMaxAliasesForCollections(MAX_YAML_ALIASES); + loaderOptions.setNestingDepthLimit(MAX_YAML_NESTING_DEPTH); + loaderOptions.setCodePointLimit(MAX_YAML_CODE_POINTS); + Yaml yaml = new Yaml(new SafeConstructor(loaderOptions)); + parsed = yaml.load(content); + } catch (Exception exception) { + return failure("SPEC_YAML_INVALID", "validation.yaml is not valid YAML: " + + exception.getMessage()); + } + if (!(parsed instanceof Map root)) { + return failure("SPEC_NOT_MAP", "validation.yaml must be a YAML object"); + } + + List errors = new ArrayList<>(); + Object versionValue = root.get("version"); + int version = 1; + if (versionValue instanceof Number number) { + version = number.intValue(); + } else if (versionValue != null) { + try { + version = Integer.parseInt(versionValue.toString().trim()); + } catch (NumberFormatException e) { + errors.add(new SpecError("SPEC_VERSION_INVALID", + "version must be an integer, got: " + versionValue)); + } + } + if (version != ValidationSpec.CURRENT_VERSION) { + errors.add(new SpecError("SPEC_VERSION_INVALID", + "unsupported spec version: " + version + " (expected " + + ValidationSpec.CURRENT_VERSION + ")")); + } + + List tasks = new ArrayList<>(); + java.util.Set seenNames = new java.util.HashSet<>(); + Object tasksValue = root.get("tasks"); + if (tasksValue == null) { + errors.add(new SpecError("SPEC_TASKS_MISSING", "validation.yaml must define a tasks list")); + } else if (!(tasksValue instanceof List rawTasks)) { + errors.add(new SpecError("SPEC_TASKS_INVALID", "tasks must be a list")); + } else { + if (rawTasks.size() > MAX_TASKS) { + errors.add(new SpecError("SPEC_TASKS_TOO_MANY", + "too many tasks: " + rawTasks.size() + " (max " + MAX_TASKS + ")")); + } + for (int i = 0; i < rawTasks.size(); i++) { + ValidationTaskSpec task = parseTask(rawTasks.get(i), i, errors, seenNames); + if (task != null) { + tasks.add(task); + } + } + } + + if (!errors.isEmpty()) { + return new ParseOutcome(ValidationSpec.empty(), List.copyOf(errors)); + } + return new ParseOutcome(new ValidationSpec(version, List.copyOf(tasks)), List.of()); + } + + private ValidationTaskSpec parseTask(Object rawTask, int index, List errors, + java.util.Set seenNames) { + String where = "tasks[" + index + "]"; + if (!(rawTask instanceof Map taskMap)) { + errors.add(new SpecError("TASK_NOT_MAP", where + " must be a YAML object")); + return null; + } + + String name = stringField(taskMap, "name"); + if (name == null || name.isBlank()) { + errors.add(new SpecError("TASK_NAME_MISSING", where + " is missing a name")); + } else if (!name.matches("[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}")) { + errors.add(new SpecError("TASK_NAME_INVALID", + where + " has an invalid name: " + name)); + } else if (!seenNames.add(name)) { + errors.add(new SpecError("TASK_NAME_DUPLICATE", where + " duplicates task name: " + name)); + } + + String typeRaw = stringField(taskMap, "type"); + TaskType type; + if (typeRaw == null) { + errors.add(new SpecError("TASK_TYPE_MISSING", where + " is missing a type")); + return null; + } + try { + type = TaskType.valueOf(typeRaw.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + errors.add(new SpecError("TASK_TYPE_INVALID", + where + " has unknown type: " + typeRaw + " (expected script or prompt)")); + return null; + } + + String script = stringField(taskMap, "script"); + String prompt = stringField(taskMap, "prompt"); + if (type == TaskType.SCRIPT && (script == null || script.isBlank())) { + errors.add(new SpecError("TASK_SCRIPT_MISSING", + where + " (script task) must define script")); + } + if (type == TaskType.PROMPT && (prompt == null || prompt.isBlank())) { + errors.add(new SpecError("TASK_PROMPT_MISSING", + where + " (prompt task) must define prompt")); + } + + List args = new ArrayList<>(); + Object argsValue = taskMap.get("args"); + if (argsValue instanceof List rawArgs) { + for (Object arg : rawArgs) { + if (arg != null) { + args.add(arg.toString()); + } + } + } else if (argsValue != null) { + errors.add(new SpecError("TASK_ARGS_INVALID", where + " args must be a list")); + } + + int timeoutMs = ValidationSpec.DEFAULT_TASK_TIMEOUT_MS; + Object timeoutValue = taskMap.get("timeoutMs"); + if (timeoutValue instanceof Number number) { + timeoutMs = number.intValue(); + } else if (timeoutValue != null) { + errors.add(new SpecError("TASK_TIMEOUT_INVALID", + where + " timeoutMs must be an integer")); + } + if (timeoutMs <= 0 || timeoutMs > MAX_TASK_TIMEOUT_MS) { + errors.add(new SpecError("TASK_TIMEOUT_INVALID", + where + " timeoutMs must be between 1 and " + MAX_TASK_TIMEOUT_MS)); + } + + List assertions = new ArrayList<>(); + Object assertionsValue = taskMap.get("assertions"); + if (assertionsValue instanceof List rawAssertions) { + for (Object rawAssertion : rawAssertions) { + if (rawAssertion instanceof Map assertionMap) { + Object assertionType = assertionMap.get("type"); + Map params = new LinkedHashMap<>(); + for (Map.Entry entry : assertionMap.entrySet()) { + if (!"type".equals(entry.getKey()) && entry.getKey() != null) { + params.put(entry.getKey().toString(), entry.getValue()); + } + } + if (assertionType == null || assertionType.toString().isBlank()) { + errors.add(new SpecError("ASSERTION_TYPE_MISSING", + where + " has an assertion without a type")); + } else { + assertions.add(new AssertionSpec(assertionType.toString(), params)); + } + } else { + errors.add(new SpecError("ASSERTION_NOT_MAP", + where + " assertions must be objects")); + } + } + } else if (assertionsValue != null) { + errors.add(new SpecError("ASSERTIONS_INVALID", where + " assertions must be a list")); + } + + String description = stringField(taskMap, "description"); + return new ValidationTaskSpec(name == null ? where : name, description, type, script, + List.copyOf(args), prompt, timeoutMs, List.copyOf(assertions)); + } + + private String stringField(Map map, String key) { + Object value = map.get(key); + return value == null ? null : value.toString(); + } + + private ParseOutcome failure(String ruleCode, String message) { + return new ParseOutcome(ValidationSpec.empty(), List.of(new SpecError(ruleCode, message))); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationTaskSpec.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationTaskSpec.java new file mode 100644 index 000000000..dce5ba21a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationTaskSpec.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +import java.util.List; + +/** + * One behavior validation task parsed from {@code validation.yaml}. + */ +public record ValidationTaskSpec( + String name, + String description, + TaskType type, + String script, + List args, + String prompt, + int timeoutMs, + List assertions +) { + + public List safeArgs() { + return args == null ? List.of() : List.copyOf(args); + } + + public List safeAssertions() { + return assertions == null ? List.of() : List.copyOf(assertions); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/package-info.java new file mode 100644 index 000000000..01f47fb37 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/package-info.java @@ -0,0 +1,4 @@ +/** + * Behavior validation task specifications parsed from a draft's {@code validation.yaml}. + */ +package com.iflytek.skillhub.domain.authoring.spec; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingDraft.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingDraft.java new file mode 100644 index 000000000..168fe36fc --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingDraft.java @@ -0,0 +1,36 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import com.iflytek.skillhub.domain.authoring.FixSuggestion; + +/** + * A finding produced by one validation layer, before it is persisted. The orchestrator + * converts these into {@link ValidationFinding} rows and emits FINDING events. + */ +public record FindingDraft( + ValidationLayer layer, + String ruleCode, + FindingSeverity severity, + String filePath, + String location, + String message, + FixSuggestion suggestion +) { + + public static FindingDraft error(ValidationLayer layer, String ruleCode, String message) { + return new FindingDraft(layer, ruleCode, FindingSeverity.ERROR, null, null, message, null); + } + + public static FindingDraft error(ValidationLayer layer, String ruleCode, String filePath, + String message, FixSuggestion suggestion) { + return new FindingDraft(layer, ruleCode, FindingSeverity.ERROR, filePath, null, message, suggestion); + } + + public static FindingDraft warning(ValidationLayer layer, String ruleCode, String filePath, + String message) { + return new FindingDraft(layer, ruleCode, FindingSeverity.WARNING, filePath, null, message, null); + } + + public static FindingDraft info(ValidationLayer layer, String ruleCode, String message) { + return new FindingDraft(layer, ruleCode, FindingSeverity.INFO, null, null, message, null); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingSeverity.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingSeverity.java new file mode 100644 index 000000000..9dd8fbf8f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingSeverity.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Severity of a validation finding. A run only fails when at least one ERROR finding exists. + */ +public enum FindingSeverity { + ERROR, + WARNING, + INFO +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingStatus.java new file mode 100644 index 000000000..08729c6b8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/FindingStatus.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Lifecycle of a fix suggestion attached to a finding. + */ +public enum FindingStatus { + /** Suggestion is available but has not been applied to the draft. */ + OPEN, + /** The suggestion's patch was applied and produced a new draft revision. */ + APPLIED, + /** The user explicitly dismissed the suggestion. */ + DISMISSED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEvent.java new file mode 100644 index 000000000..366c66e74 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEvent.java @@ -0,0 +1,98 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * One persisted run event. {@code seq} is a per-run monotonic counter used both for + * ordered display and for SSE resume (Last-Event-ID) and polling (afterSeq) cursors. + */ +@Entity +@Table(name = "validation_event", + uniqueConstraints = @jakarta.persistence.UniqueConstraint( + name = "uq_validation_event_seq", columnNames = {"run_id", "seq"})) +public class ValidationEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "run_id", nullable = false) + private Long runId; + + @Column(nullable = false) + private Integer seq; + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 32) + private ValidationEventType eventType; + + @Column(length = 32) + private String phase; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "payload") + private Map payload; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected ValidationEvent() { + } + + public ValidationEvent(Long runId, Integer seq, ValidationEventType eventType, + String phase, Map payload) { + this.runId = runId; + this.seq = seq; + this.eventType = eventType; + this.phase = phase; + this.payload = payload; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + // Getters + + public Long getId() { + return id; + } + + public Long getRunId() { + return runId; + } + + public Integer getSeq() { + return seq; + } + + public ValidationEventType getEventType() { + return eventType; + } + + public String getPhase() { + return phase; + } + + public Map getPayload() { + return payload; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventRepository.java new file mode 100644 index 000000000..a766e2665 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventRepository.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for persisted validation events. + */ +public interface ValidationEventRepository { + + ValidationEvent save(ValidationEvent event); + + List findByRunIdOrderBySeqAsc(Long runId); + + /** Events with seq strictly greater than the cursor, ordered for replay. */ + List findByRunIdAndSeqGreaterThanOrderBySeqAsc(Long runId, int afterSeq); + + List findByRunIdAndSeqIn(Long runId, List seqs); + + Optional findMaxSeqByRunId(Long runId); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventType.java new file mode 100644 index 000000000..db724dc84 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationEventType.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Event types emitted during a validation run and persisted in order. The sequence + * number of each event enables SSE replay with Last-Event-ID resumption. + */ +public enum ValidationEventType { + RUN_STARTED, + PHASE_STARTED, + PHASE_FINISHED, + AGENT_MESSAGE, + TOOL_CALL, + TOOL_RESULT, + LOG, + FINDING, + RUN_FINISHED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFinding.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFinding.java new file mode 100644 index 000000000..0d0d8d835 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFinding.java @@ -0,0 +1,152 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Clock; +import java.time.Instant; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import com.iflytek.skillhub.domain.authoring.FixSuggestion; + +/** + * A single problem or observation produced by one validation layer, optionally carrying + * a machine-applicable fix suggestion (patch payload) that the author can preview, + * confirm, and apply to produce a new draft revision. + */ +@Entity +@Table(name = "validation_finding") +public class ValidationFinding { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "run_id", nullable = false) + private Long runId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private ValidationLayer layer; + + @Column(name = "rule_code", nullable = false, length = 64) + private String ruleCode; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private FindingSeverity severity; + + @Column(name = "file_path", length = 512) + private String filePath; + + @Column(length = 128) + private String location; + + @Column(nullable = false, columnDefinition = "TEXT") + private String message; + + /** Machine-applicable fix payload; null when no machine-applicable fix exists. */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "suggestion") + private FixSuggestion suggestion; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private FindingStatus status = FindingStatus.OPEN; + + @Column(name = "applied_revision") + private Integer appliedRevision; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected ValidationFinding() { + } + + public ValidationFinding(Long runId, ValidationLayer layer, String ruleCode, + FindingSeverity severity, String filePath, String location, + String message, FixSuggestion suggestion) { + this.runId = runId; + this.layer = layer; + this.ruleCode = ruleCode; + this.severity = severity; + this.filePath = filePath; + this.location = location; + this.message = message; + this.suggestion = suggestion; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + public void markApplied(int appliedRevision) { + this.status = FindingStatus.APPLIED; + this.appliedRevision = appliedRevision; + } + + public void markDismissed() { + this.status = FindingStatus.DISMISSED; + } + + public boolean hasSuggestion() { + return suggestion != null && !suggestion.safePatches().isEmpty(); + } + + // Getters + + public Long getId() { + return id; + } + + public Long getRunId() { + return runId; + } + + public ValidationLayer getLayer() { + return layer; + } + + public String getRuleCode() { + return ruleCode; + } + + public FindingSeverity getSeverity() { + return severity; + } + + public String getFilePath() { + return filePath; + } + + public String getLocation() { + return location; + } + + public String getMessage() { + return message; + } + + public FixSuggestion getSuggestion() { + return suggestion; + } + + public FindingStatus getStatus() { + return status; + } + + public Integer getAppliedRevision() { + return appliedRevision; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFindingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFindingRepository.java new file mode 100644 index 000000000..9fb21b422 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationFindingRepository.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for validation findings. + */ +public interface ValidationFindingRepository { + + ValidationFinding save(ValidationFinding finding); + + Optional findById(Long id); + + List findByRunId(Long runId); + + List findByRunIdAndStatus(Long runId, FindingStatus status); + + List findByRunIdAndStatusAndLayer(Long runId, FindingStatus status, ValidationLayer layer); + + List findByRunIdIn(List runIds); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationLayer.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationLayer.java new file mode 100644 index 000000000..4ca2694d7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationLayer.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * The three validation layers executed by every run, in order. + */ +public enum ValidationLayer { + STRUCTURE, + CONFIG, + BEHAVIOR +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRun.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRun.java new file mode 100644 index 000000000..ecb723960 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRun.java @@ -0,0 +1,191 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * One immutable validation execution bound to a specific draft revision. + * + *

Transitions are guarded: only the executor moves a run forward and only + * active runs can be cancelled or timed out. {@code cancelRequested} is a + * cooperative flag set by the cancel API and honored between tasks. + */ +@Entity +@Table(name = "validation_run") +public class ValidationRun { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "draft_id", nullable = false) + private Long draftId; + + @Column(name = "draft_revision", nullable = false) + private Integer draftRevision; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ValidationRunStatus status = ValidationRunStatus.QUEUED; + + @Column(name = "cancel_requested", nullable = false) + private boolean cancelRequested; + + @Column(name = "error_count", nullable = false) + private Integer errorCount = 0; + + @Column(name = "warning_count", nullable = false) + private Integer warningCount = 0; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "summary") + private Map summary; + + @Column(name = "triggered_by", nullable = false, length = 128) + private String triggeredBy; + + @Column(name = "started_at") + private Instant startedAt; + + @Column(name = "finished_at") + private Instant finishedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Version + @Column(name = "version", nullable = false) + private Long optimisticVersion; + + protected ValidationRun() { + } + + public ValidationRun(Long draftId, Integer draftRevision, String triggeredBy) { + this.draftId = draftId; + this.draftRevision = draftRevision; + this.triggeredBy = triggeredBy; + this.status = ValidationRunStatus.QUEUED; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(Clock.systemUTC()); + } + + public void markPreparing() { + requireActive("markPreparing"); + if (status != ValidationRunStatus.QUEUED) { + throw new DomainConflictException("error.authoring.run.notQueued", id); + } + status = ValidationRunStatus.PREPARING; + } + + public void markRunning(Instant now) { + requireActive("markRunning"); + status = ValidationRunStatus.RUNNING; + startedAt = now; + } + + /** + * Settles the run into a terminal status and records finding counters plus a + * machine-readable summary. The status must be one of the natural outcomes + * (SUCCEEDED / FAILED / CANCELLED / TIMED_OUT). + */ + public void finish(ValidationRunStatus terminalStatus, int errorCount, int warningCount, + Map summary, Instant now) { + if (terminalStatus == null || !terminalStatus.isTerminal()) { + throw new IllegalArgumentException("Terminal status required: " + terminalStatus); + } + requireActive("finish"); + status = terminalStatus; + this.errorCount = errorCount; + this.warningCount = warningCount; + this.summary = summary; + this.finishedAt = now; + } + + /** Idempotent cooperative cancel request; repeated calls keep the first state. */ + public void requestCancel() { + if (isActive()) { + cancelRequested = true; + } + } + + public boolean isCancelRequested() { + return cancelRequested; + } + + public boolean isActive() { + return status.isActive(); + } + + private void requireActive(String action) { + if (status.isTerminal()) { + throw new DomainConflictException("error.authoring.run.terminal", id, status); + } + } + + // Getters + + public Long getId() { + return id; + } + + public Long getDraftId() { + return draftId; + } + + public Integer getDraftRevision() { + return draftRevision; + } + + public ValidationRunStatus getStatus() { + return status; + } + + public Integer getErrorCount() { + return errorCount; + } + + public Integer getWarningCount() { + return warningCount; + } + + public Map getSummary() { + return summary; + } + + public String getTriggeredBy() { + return triggeredBy; + } + + public Instant getStartedAt() { + return startedAt; + } + + public Instant getFinishedAt() { + return finishedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Long getOptimisticVersion() { + return optimisticVersion; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunRepository.java new file mode 100644 index 000000000..689f464d8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunRepository.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * Domain repository contract for validation runs. + */ +public interface ValidationRunRepository { + + ValidationRun save(ValidationRun run); + + Optional findById(Long id); + + Optional findByIdForUpdate(Long id); + + List findByDraftIdOrderByCreatedAtDesc(Long draftId); + + /** Latest run recorded for an exact draft revision, used by the submit gate. */ + Optional findFirstByDraftIdAndDraftRevisionOrderByCreatedAtDesc( + Long draftId, Integer draftRevision); + + /** Runs still in a non-terminal state, used by the maintenance sweep. */ + List findByStatusIn(List statuses); + + List findByStatusInAndCreatedAtBefore(List statuses, Instant cutoff); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunStatus.java new file mode 100644 index 000000000..e262099d7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/ValidationRunStatus.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.domain.authoring.validation; + +/** + * Lifecycle of one validation run. Runs are immutable snapshots of a draft revision: + * once started they execute the structure, configuration, and behavior layers in order + * and settle into a terminal status. + */ +public enum ValidationRunStatus { + QUEUED, + PREPARING, + RUNNING, + SUCCEEDED, + FAILED, + CANCELLED, + TIMED_OUT; + + public boolean isTerminal() { + return this == SUCCEEDED || this == FAILED || this == CANCELLED || this == TIMED_OUT; + } + + public boolean isActive() { + return !isTerminal(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/package-info.java new file mode 100644 index 000000000..c33cc2b7f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/validation/package-info.java @@ -0,0 +1,5 @@ +/** + * Validation runs, persisted event streams, and findings produced by the structure, + * configuration, and behavior validation layers. + */ +package com.iflytek.skillhub.domain.authoring.validation; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluatorTest.java new file mode 100644 index 000000000..d3760764e --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluatorTest.java @@ -0,0 +1,138 @@ +package com.iflytek.skillhub.domain.authoring.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.authoring.spec.AssertionSpec; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class AssertionEvaluatorTest { + + private final AssertionEvaluator evaluator = new AssertionEvaluator(); + + private static TaskResult scriptResult(int exitCode, String stdout, String stderr) { + return TaskResult.ofScript(exitCode, stdout, stderr, 1, path -> Optional.empty()); + } + + @Test + void exitCodeAssertionPassesAndFails() { + AssertionSpec assertion = new AssertionSpec("exit_code", Map.of("equals", 0)); + + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "", ""))).isEmpty(); + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(1, "", ""))) + .singleElement() + .satisfies(failure -> assertThat(failure.describe()).contains("exit code")); + } + + @Test + void stdoutContainsChecksValue() { + AssertionSpec assertion = new AssertionSpec("stdout_contains", Map.of("value", "OK")); + + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "everything OK here", ""))) + .isEmpty(); + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "failed", ""))) + .hasSize(1); + } + + @Test + void stdoutMatchesUsesRegexFindSemantics() { + AssertionSpec assertion = + new AssertionSpec("stdout_matches", Map.of("pattern", "duration: \\d+ms")); + + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "run finished, duration: 42ms", ""))) + .isEmpty(); + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "no timing info", ""))) + .hasSize(1); + } + + @Test + void jsonPointerAssertionComparesValue() { + AssertionSpec assertion = new AssertionSpec("stdout_json", + Map.of("pointer", "/status", "equals", "healthy")); + + assertThat(evaluator.evaluate(List.of(assertion), + scriptResult(0, "{\"status\":\"healthy\"}", ""))).isEmpty(); + assertThat(evaluator.evaluate(List.of(assertion), + scriptResult(0, "{\"status\":\"unhealthy\"}", ""))).hasSize(1); + assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "not json", ""))) + .singleElement() + .satisfies(failure -> assertThat(failure.describe()).contains("not valid JSON")); + } + + @Test + void artifactExistsChecksContentHash() { + byte[] content = "artifact-bytes".getBytes(); + String sha = TaskResult.sha256Hex(content); + TaskResult result = TaskResult.ofScript(0, "", "", 0, path -> + "out/report.txt".equals(path) ? Optional.of(content) : Optional.empty()); + + AssertionSpec exists = new AssertionSpec("artifact_exists", Map.of("path", "out/report.txt")); + AssertionSpec missing = new AssertionSpec("artifact_exists", Map.of("path", "out/none.txt")); + AssertionSpec wrongHash = new AssertionSpec("artifact_exists", + Map.of("path", "out/report.txt", "sha256", "deadbeef")); + + assertThat(evaluator.evaluate(List.of(exists), result)).isEmpty(); + assertThat(evaluator.evaluate(List.of(missing), result)).hasSize(1); + assertThat(evaluator.evaluate(List.of(wrongHash), result)) + .singleElement() + .satisfies(failure -> assertThat(failure.describe()).contains("sha256")); + } + + @Test + void toolCallCountHonorsMinAndMax() { + TaskResult result = TaskResult.ofScript(0, "", "", 2, path -> Optional.empty()); + + assertThat(evaluator.evaluate( + List.of(new AssertionSpec("tool_call_count", Map.of("min", 1, "max", 3))), result)) + .isEmpty(); + assertThat(evaluator.evaluate( + List.of(new AssertionSpec("tool_call_count", Map.of("min", 3))), result)) + .hasSize(1); + assertThat(evaluator.evaluate( + List.of(new AssertionSpec("tool_call_count", Map.of("max", 1))), result)) + .hasSize(1); + } + + @Test + void responseAssertionsApplyToPromptResults() { + TaskResult result = TaskResult.ofPrompt("the skill summarizes documents", 0, + path -> Optional.empty()); + + assertThat(evaluator.evaluate(List.of( + new AssertionSpec("response_contains", Map.of("value", "summarizes"))), result)) + .isEmpty(); + assertThat(evaluator.evaluate(List.of( + new AssertionSpec("response_contains", Map.of("value", "translates"))), result)) + .hasSize(1); + } + + @Test + void unknownAssertionTypeFailsEvaluation() { + List assertions = List.of(new AssertionSpec("mind_read", Map.of())); + + assertThat(evaluator.evaluate(assertions, scriptResult(0, "", ""))) + .singleElement() + .satisfies(failure -> assertThat(failure.describe()).contains("unknown assertion type")); + } + + @Test + void validateSyntaxAcceptsKnownTypesPerTaskKind() { + List scriptAssertions = List.of( + new AssertionSpec("exit_code", Map.of("equals", 0)), + new AssertionSpec("stdout_contains", Map.of("value", "OK"))); + List promptAssertions = List.of( + new AssertionSpec("response_contains", Map.of("value", "OK")), + new AssertionSpec("tool_call_count", Map.of("min", 0))); + + assertThat(evaluator.validateSyntax(scriptAssertions, false)).isEmpty(); + assertThat(evaluator.validateSyntax(promptAssertions, true)).isEmpty(); + + List crossKind = List.of( + new AssertionSpec("exit_code", Map.of("equals", 0)), + new AssertionSpec("response_contains", Map.of("value", "OK"))); + assertThat(evaluator.validateSyntax(crossKind, true)).hasSize(1); + assertThat(evaluator.validateSyntax(crossKind, false)).hasSize(1); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidatorTest.java new file mode 100644 index 000000000..1bddf7f4d --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/DraftStructureValidatorTest.java @@ -0,0 +1,112 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.authoring.FilePatch; +import com.iflytek.skillhub.domain.authoring.FixSuggestion; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; + +class DraftStructureValidatorTest { + + private final DraftStructureValidator validator = new DraftStructureValidator( + new SkillMetadataParser(), new SkillScaffoldGenerator()); + + private static PackageEntry entry(String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + return new PackageEntry(path, bytes, bytes.length, "text/plain"); + } + + @Test + void validPackageProducesNoFindings() { + DraftStructureValidator.StructureReport report = validator.validate("demo", "does demo things", + List.of(entry("SKILL.md", validSkillMd()), entry("scripts/check.sh", "echo OK"))); + + assertThat(report.findings()).isEmpty(); + assertThat(report.skillMdPresent()).isTrue(); + assertThat(report.hasErrors()).isFalse(); + } + + @Test + void missingSkillMdYieldsScaffoldSuggestion() { + DraftStructureValidator.StructureReport report = validator.validate("demo", "does demo things", + List.of(entry("scripts/check.sh", "echo OK"))); + + assertThat(report.skillMdPresent()).isFalse(); + assertThat(report.hasErrors()).isTrue(); + FindingDraft finding = report.findings().get(0); + assertThat(finding.ruleCode()).isEqualTo("SKILL_MD_MISSING"); + FixSuggestion suggestion = finding.suggestion(); + assertThat(suggestion).isNotNull(); + assertThat(suggestion.patches()).singleElement().satisfies(patch -> { + assertThat(patch.filePath()).isEqualTo("SKILL.md"); + assertThat(patch.isCreation()).isTrue(); + assertThat(patch.newValue()).contains("name: demo"); + }); + } + + @Test + void missingRequiredFrontmatterFieldYieldsInsertPatch() { + String skillMd = "---\nname: demo\n---\n\n# Demo\n"; + DraftStructureValidator.StructureReport report = validator.validate("demo", null, + List.of(entry("SKILL.md", skillMd))); + + assertThat(report.hasErrors()).isTrue(); + FindingDraft finding = report.findings().get(0); + assertThat(finding.ruleCode()).isEqualTo("FRONTMATTER_FIELD_MISSING"); + FixSuggestion suggestion = finding.suggestion(); + assertThat(suggestion).isNotNull(); + FilePatch patch = suggestion.patches().get(0); + assertThat(patch.filePath()).isEqualTo("SKILL.md"); + assertThat(patch.oldSha256()).isEqualTo( + com.iflytek.skillhub.domain.authoring.runtime.TaskResult.sha256Hex(skillMd)); + assertThat(patch.oldValue()).isEqualTo(skillMd); + // the missing field is inserted inside the frontmatter block, keeping valid YAML + assertThat(patch.newValue()).startsWith("---\n"); + assertThat(patch.newValue()).contains("description: TODO: describe what this skill does"); + assertThat(patch.newValue()).contains("name: demo"); + assertThat(patch.newValue()).endsWith("---\n\n# Demo\n"); + } + + @Test + void frontmatterWithoutOpeningMarkerIsReportedWithoutSuggestion() { + DraftStructureValidator.StructureReport report = validator.validate("demo", null, + List.of(entry("SKILL.md", "name: demo\ndescription: x\n"))); + + assertThat(report.hasErrors()).isTrue(); + assertThat(report.findings().get(0).ruleCode()).isEqualTo("FRONTMATTER_MISSING_START"); + assertThat(report.findings().get(0).suggestion()).isNull(); + } + + @Test + void disallowedExtensionIsAWarning() { + DraftStructureValidator.StructureReport report = validator.validate("demo", null, + List.of(entry("SKILL.md", validSkillMd()), entry("binary.exe", "MZ"))); + + assertThat(report.findings()).singleElement().satisfies(finding -> { + assertThat(finding.ruleCode()).isEqualTo("EXTENSION_DISALLOWED"); + assertThat(finding.severity()).isEqualTo( + com.iflytek.skillhub.domain.authoring.validation.FindingSeverity.WARNING); + assertThat(finding.layer()).isEqualTo(ValidationLayer.STRUCTURE); + }); + } + + @Test + void invalidPathIsAnError() { + DraftStructureValidator.StructureReport report = validator.validate("demo", null, + List.of(entry("SKILL.md", validSkillMd()), entry("../escape.sh", "echo hi"))); + + assertThat(report.hasErrors()).isTrue(); + assertThat(report.findings().stream().map(FindingDraft::ruleCode)) + .contains("PATH_INVALID"); + } + + private String validSkillMd() { + return "---\nname: demo\ndescription: A demo skill\n---\n\n# Demo\n"; + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGeneratorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGeneratorTest.java new file mode 100644 index 000000000..9009728e4 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/service/SkillScaffoldGeneratorTest.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import org.junit.jupiter.api.Test; + +class SkillScaffoldGeneratorTest { + + private final SkillScaffoldGenerator generator = new SkillScaffoldGenerator(); + private final SkillMetadataParser parser = new SkillMetadataParser(); + + @Test + void skillMdScaffoldHasValidFrontmatter() { + String scaffold = generator.generateSkillMd("My Demo Skill", "It summarizes documents."); + + var metadata = parser.parse(scaffold); + assertThat(metadata.name()).isEqualTo("my-demo-skill"); + assertThat(metadata.description()).isEqualTo("It summarizes documents."); + assertThat(scaffold).contains("# my-demo-skill"); + } + + @Test + void blankRequirementFallsBackToPlaceholder() { + String scaffold = generator.generateSkillMd("demo", " "); + + var metadata = parser.parse(scaffold); + assertThat(metadata.description()).isNotBlank(); + assertThat(scaffold).contains("Describe what this skill does"); + } + + @Test + void validationYamlScaffoldParses() { + String yaml = generator.generateValidationYaml(); + + var outcome = new com.iflytek.skillhub.domain.authoring.spec.ValidationSpecParser().parse(yaml); + assertThat(outcome.isValid()).as("scaffold validation.yaml must parse cleanly: %s", outcome.errors()) + .isTrue(); + assertThat(outcome.spec().safeTasks()).singleElement().satisfies(task -> { + assertThat(task.name()).isEqualTo("smoke"); + assertThat(task.script()).isEqualTo("scripts/check.sh"); + }); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParserTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParserTest.java new file mode 100644 index 000000000..c1cda0bb1 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParserTest.java @@ -0,0 +1,182 @@ +package com.iflytek.skillhub.domain.authoring.spec; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class ValidationSpecParserTest { + + private final ValidationSpecParser parser = new ValidationSpecParser(); + + @Test + void parsesValidScriptTask() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: smoke + description: basic check + type: script + script: scripts/check.sh + args: ["--quiet"] + timeoutMs: 5000 + assertions: + - type: exit_code + equals: 0 + - type: stdout_contains + value: OK + """); + + assertThat(outcome.isValid()).isTrue(); + ValidationSpec spec = outcome.spec(); + assertThat(spec.version()).isEqualTo(1); + assertThat(spec.safeTasks()).hasSize(1); + ValidationTaskSpec task = spec.safeTasks().get(0); + assertThat(task.name()).isEqualTo("smoke"); + assertThat(task.type()).isEqualTo(TaskType.SCRIPT); + assertThat(task.script()).isEqualTo("scripts/check.sh"); + assertThat(task.safeArgs()).containsExactly("--quiet"); + assertThat(task.timeoutMs()).isEqualTo(5000); + assertThat(task.safeAssertions()).hasSize(2); + assertThat(task.safeAssertions().get(0).type()).isEqualTo("exit_code"); + assertThat(task.safeAssertions().get(0).paramInt("equals")).isZero(); + } + + @Test + void parsesPromptTaskWithoutOptionalFields() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: ask + type: prompt + prompt: What is this skill about? + """); + + assertThat(outcome.isValid()).isTrue(); + ValidationTaskSpec task = outcome.spec().safeTasks().get(0); + assertThat(task.type()).isEqualTo(TaskType.PROMPT); + assertThat(task.timeoutMs()).isEqualTo(ValidationSpec.DEFAULT_TASK_TIMEOUT_MS); + assertThat(task.safeAssertions()).isEmpty(); + } + + @Test + void emptyContentIsASpecError() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(" \n"); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_EMPTY")); + } + + @Test + void invalidYamlIsASpecErrorNotAnException() { + ValidationSpecParser.ParseOutcome outcome = parser.parse("version: [unbalanced"); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_YAML_INVALID")); + } + + @Test + void unsupportedVersionIsRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 2 + tasks: [] + """); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_VERSION_INVALID")); + } + + @Test + void missingTasksListIsRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse("version: 1\n"); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_TASKS_MISSING")); + } + + @Test + void duplicateTaskNamesAreRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: smoke + type: script + script: a.sh + - name: smoke + type: script + script: b.sh + """); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("TASK_NAME_DUPLICATE")); + } + + @Test + void scriptTaskRequiresScriptAndPromptTaskRequiresPrompt() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: no-script + type: script + - name: no-prompt + type: prompt + """); + + List codes = outcome.errors().stream().map(ValidationSpecParser.SpecError::ruleCode).toList(); + assertThat(codes).containsExactly("TASK_SCRIPT_MISSING", "TASK_PROMPT_MISSING"); + } + + @Test + void unknownTypeAndInvalidTimeoutAreRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: weird + type: telepathy + - name: slow + type: script + script: a.sh + timeoutMs: 999999999 + """); + + List codes = outcome.errors().stream().map(ValidationSpecParser.SpecError::ruleCode).toList(); + assertThat(codes).contains("TASK_TYPE_INVALID", "TASK_TIMEOUT_INVALID"); + } + + @Test + void invalidTaskNameIsRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: "has space" + type: script + script: a.sh + """); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("TASK_NAME_INVALID")); + } + + @Test + void assertionWithoutTypeIsRejected() { + ValidationSpecParser.ParseOutcome outcome = parser.parse(""" + version: 1 + tasks: + - name: smoke + type: script + script: a.sh + assertions: + - equals: 0 + """); + + assertThat(outcome.isValid()).isFalse(); + assertThat(outcome.errors()).singleElement() + .satisfies(error -> assertThat(error.ruleCode()).isEqualTo("ASSERTION_TYPE_MISSING")); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/DraftFileJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/DraftFileJpaRepository.java new file mode 100644 index 000000000..458bc8187 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/DraftFileJpaRepository.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.DraftFile; +import com.iflytek.skillhub.domain.authoring.DraftFileRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for draft files. + */ +@Repository +public interface DraftFileJpaRepository extends JpaRepository, DraftFileRepository { + + List findByDraftIdOrderByFilePath(Long draftId); + + Optional findByDraftIdAndFilePath(Long draftId, String filePath); + + List findByDraftIdAndFilePathIn(Long draftId, List filePaths); + + long countByDraftId(Long draftId); + + @Query("SELECT COALESCE(SUM(file.size), 0) FROM DraftFile file WHERE file.draftId = :draftId") + long sumSizeByDraftId(@Param("draftId") Long draftId); + + void deleteByDraftId(Long draftId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/RuntimeBindingJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/RuntimeBindingJpaRepository.java new file mode 100644 index 000000000..c8b354fbf --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/RuntimeBindingJpaRepository.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.RuntimeBinding; +import com.iflytek.skillhub.domain.authoring.RuntimeBindingRepository; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for per-draft runtime bindings. + */ +@Repository +public interface RuntimeBindingJpaRepository extends JpaRepository, + RuntimeBindingRepository { + + Optional findByDraftId(Long draftId); + + void deleteByDraftId(Long draftId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillDraftJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillDraftJpaRepository.java new file mode 100644 index 000000000..db1398908 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillDraftJpaRepository.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for skill authoring drafts. + */ +@Repository +public interface SkillDraftJpaRepository extends JpaRepository, SkillDraftRepository { + + List findByOwnerIdOrderByUpdatedAtDesc(String ownerId); + + List findByNamespaceId(Long namespaceId); + + Optional findByOwnerIdAndNameIgnoreCase(String ownerId, String name); + + boolean existsByIdAndOwnerId(Long id, String ownerId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationEventJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationEventJpaRepository.java new file mode 100644 index 000000000..8bcdafd2e --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationEventJpaRepository.java @@ -0,0 +1,30 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent; +import com.iflytek.skillhub.domain.authoring.validation.ValidationEventRepository; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for validation run events. + */ +@Repository +public interface ValidationEventJpaRepository extends JpaRepository, + ValidationEventRepository { + + List findByRunIdOrderBySeqAsc(Long runId); + + List findByRunIdAndSeqGreaterThanOrderBySeqAsc(Long runId, int afterSeq); + + List findByRunIdAndSeqIn(Long runId, List seqs); + + @Override + @Query("SELECT MAX(event.seq) FROM ValidationEvent event WHERE event.runId = :runId") + Optional findMaxSeqByRunId(@Param("runId") Long runId); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationFindingJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationFindingJpaRepository.java new file mode 100644 index 000000000..03696b0f7 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationFindingJpaRepository.java @@ -0,0 +1,31 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFindingRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import com.iflytek.skillhub.domain.authoring.validation.FindingStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for validation findings. + */ +@Repository +public interface ValidationFindingJpaRepository extends JpaRepository, + ValidationFindingRepository { + + Optional findById(Long id); + + List findByRunId(Long runId); + + List findByRunIdAndStatus(Long runId, FindingStatus status); + + List findByRunIdAndStatusAndLayer( + Long runId, FindingStatus status, ValidationLayer layer); + + List findByRunIdIn(List runIds); + + void deleteByRunId(Long runId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationRunJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationRunJpaRepository.java new file mode 100644 index 000000000..d9706ea11 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ValidationRunJpaRepository.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * JPA-backed repository for validation runs. + * + *

The claim query uses explicit {@code FOR UPDATE} SQL for the same H2 PostgreSQL + * compatibility reason documented on {@link SkillVersionJpaRepository}. + */ +@Repository +public interface ValidationRunJpaRepository extends JpaRepository, + ValidationRunRepository { + + @Override + @Query(value = "SELECT * FROM validation_run WHERE id = :id FOR UPDATE", nativeQuery = true) + Optional findByIdForUpdate(@Param("id") Long id); + + List findByDraftIdOrderByCreatedAtDesc(Long draftId); + + Optional findFirstByDraftIdAndDraftRevisionOrderByCreatedAtDesc( + Long draftId, Integer draftRevision); + + List findByStatusIn(List statuses); + + List findByStatusInAndCreatedAtBefore( + List statuses, Instant cutoff); +} diff --git a/web/e2e/authoring-flow.spec.ts b/web/e2e/authoring-flow.spec.ts new file mode 100644 index 000000000..86ce2c662 --- /dev/null +++ b/web/e2e/authoring-flow.spec.ts @@ -0,0 +1,370 @@ +import crypto from 'node:crypto' +import { expect, test, type APIResponse, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' +import { csrfHeaders } from './helpers/csrf' + +/** + * End-to-end coverage for the skill authoring workbench against the real API: + * draft creation, file editing (text + binary assets), runtime binding, + * validation runs with the fix-preview loop, and the submit gate. Assertions + * ride on durable UI state (button enablement, badges, response bodies) rather + * than toasts so they do not race the toast auto-dismiss. + */ + +interface ApiEnvelope { + code: number + msg?: string + data: T +} + +interface DraftSummary { + id: number + name: string +} + +interface DraftFileSummaryPayload { + path: string + sha256: string + size: number + contentType?: string +} + +// Minimal 1×1 transparent PNG used for the binary-asset upload test. +const PNG_BUFFER = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', +) + +function uniqueSkillName(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` +} + +function validSkillMd(name: string): string { + return `--- +name: ${name} +description: greets the caller with a fixed message +--- + +# ${name} + +## Overview + +greets the caller with a fixed message +` +} + +function brokenSkillMd(name: string): string { + return `--- +name: ${name} +--- + +# ${name} +` +} + +const GREET_SCRIPT = 'echo hello from authoring e2e\n' + +const VALIDATION_YAML = `version: 1 +tasks: + - name: greet + description: script prints the greeting + type: script + script: scripts/greet.sh + args: [] + timeoutMs: 30000 + assertions: + - type: exit_code + equals: 0 + - type: stdout_contains + value: hello from authoring e2e +` + +async function createDraftViaApi(page: Page, namespaceSlug: string, name: string): Promise { + const response = await page.context().request.post('/api/web/authoring/drafts', { + data: { namespaceSlug, name }, + headers: await csrfHeaders(page), + }) + const body = await response.json() as ApiEnvelope + expect(body.code, `draft creation failed: ${body.msg ?? 'unknown error'}`).toBe(0) + return body.data.id +} + +async function deleteDraftViaApi(page: Page, draftId: number): Promise { + try { + await page.context().request.delete(`/api/web/authoring/drafts/${draftId}`, { + headers: await csrfHeaders(page), + }) + } catch { + // Best-effort cleanup for E2E environments. + } +} + +function waitForDraftFileRead(page: Page, path: string, draftId?: number): Promise { + // draftId is optional so the waiter can be registered BEFORE navigating to + // the draft page — the editor fetches the selected file on mount, and a + // waiter registered after navigation can miss the response entirely. + return page.waitForResponse( + (response) => { + const url = response.url() + return response.request().method() === 'GET' + && url.includes('/api/web/authoring/drafts/') + && url.includes('/files/content') + && url.includes(`path=${encodeURIComponent(path)}`) + && (draftId === undefined + || url.includes(`/api/web/authoring/drafts/${draftId}/files/content`)) + }, + ) +} + +function waitForDraftFileSave(page: Page, draftId: number): Promise { + return page.waitForResponse( + (response) => + response.request().method() === 'PUT' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/files`) + && !response.url().includes('/runtime'), + ) +} + +/** Creates a text file through the workbench UI and fills it with `content`. */ +async function createFileViaUi(page: Page, draftId: number, path: string, content: string): Promise { + await page.locator('input[placeholder="scripts/run.py"]').fill(path) + // register before the click: the editor swap triggers a content read of the + // new (empty) file once the create PUT resolves, and waiting for that read + // keeps the fill below from being overwritten by the fetch resolving late. + const newFileRead = waitForDraftFileRead(page, path, draftId) + await page.getByRole('button', { name: 'Create file' }).click() + await newFileRead + await expect(page.getByTestId('draft-file-list')).toContainText(path) + const editor = page.getByTestId('file-editor') + await editor.fill(content) + const saveResponse = waitForDraftFileSave(page, draftId) + await page.getByTestId('save-file').click() + expect((await saveResponse).status()).toBe(200) +} + +test.describe('Authoring Flow UI (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('create → edit → bind → validate → submit through the workbench UI', async ({ page }, testInfo) => { + test.setTimeout(180_000) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + let draftId: number | undefined + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = uniqueSkillName('authoring-ui') + + // --- create the draft from the workbench list + await page.goto('/dashboard/authoring') + await expect(page.getByRole('heading', { name: 'Skill authoring' })).toBeVisible() + await page.getByTestId('create-draft').click() + const createDialog = page.getByRole('dialog') + await expect(createDialog).toBeVisible() + await createDialog.getByTestId('draft-namespace').click() + const namespaceOption = page.getByRole('option', { name: namespace.displayName }).first() + await namespaceOption.evaluate((element) => { + element.scrollIntoView({ block: 'center' }) + element.click() + }) + await expect(createDialog.getByTestId('draft-namespace')).toContainText(namespace.displayName) + await createDialog.getByTestId('draft-name').fill(skillName) + await createDialog.getByTestId('submit-create-draft').click() + + const draftLink = page.getByRole('link', { name: skillName, exact: true }) + await expect(draftLink).toBeVisible({ timeout: 15_000 }) + const skillMdRead = waitForDraftFileRead(page, 'SKILL.md') + await draftLink.click() + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + draftId = Number(page.url().split('/').pop()) + expect(Number.isFinite(draftId)).toBeTruthy() + + // --- files tab: replace the scaffold, add the script and validation spec + const editor = page.getByTestId('file-editor') + await expect(editor).toBeVisible() + // the freshly created draft serves its scaffold immediately (regression + // guard: creation must persist content, not just the metadata row) + expect((await skillMdRead).status()).toBe(200) + await expect(editor).toHaveValue(/name: /) + await editor.fill(validSkillMd(skillName)) + const skillMdSave = waitForDraftFileSave(page, draftId) + await page.getByTestId('save-file').click() + expect((await skillMdSave).status()).toBe(200) + + await createFileViaUi(page, draftId, 'scripts/greet.sh', GREET_SCRIPT) + await createFileViaUi(page, draftId, 'validation.yaml', VALIDATION_YAML) + await expect(page.getByTestId('draft-file-list')).toContainText('SKILL.md') + + // --- runtime binding tab: keep the default local-script/sh binding + await page.getByRole('tab', { name: 'Runtime binding' }).click() + await expect(page.getByTestId('agent-type')).toBeVisible() + await expect(page.locator('#interpreter')).toHaveValue('sh') + const bindingSave = page.waitForResponse( + (response) => + response.request().method() === 'PUT' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/runtime`), + ) + await page.getByTestId('save-binding').click() + expect((await bindingSave).status()).toBe(200) + + // --- validate: the run should pass all three layers + await page.getByRole('tab', { name: 'Files' }).click() + const runStart = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/runs`), + ) + await page.getByTestId('start-run').click() + expect((await runStart).status()).toBe(200) + await expect(page.getByTestId('run-status')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('run-status')).toHaveText('Succeeded', { timeout: 90_000 }) + await expect(page.getByTestId('error-count')).toHaveText('0') + await expect(page.getByTestId('task-count')).toHaveText('1') + // the behavior task's stdout lands in the event console + await expect(page.getByText('hello from authoring e2e')).toBeVisible() + + // --- back to the draft and submit through the gated dialog + await page.getByRole('link', { name: 'Back to draft' }).click() + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + await expect(page.getByText('Validated at revision')).toBeVisible({ timeout: 15_000 }) + const submitButton = page.getByTestId('submit-draft') + await expect(submitButton).toBeEnabled() + await submitButton.click() + const submitDialog = page.getByTestId('submit-dialog') + await expect(submitDialog).toBeVisible() + const submitResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/submit`), + { timeout: 120_000 }, + ) + await submitDialog.getByRole('button', { name: 'Submit', exact: true }).click() + const submitResponse = await submitResponsePromise + expect(submitResponse.status()).toBe(200) + const submitBody = await submitResponse.json() as ApiEnvelope<{ skillId: number }> + expect(submitBody.code, `submit failed: ${submitBody.msg ?? 'unknown error'}`).toBe(0) + expect(submitBody.data.skillId).toBeTruthy() + await expect(page.getByTestId('submit-draft')).toBeDisabled() + await expect(page.getByTestId('submit-draft')).toContainText('Submitted') + } finally { + if (draftId !== undefined) { + await deleteDraftViaApi(page, draftId) + } + await builder.cleanup() + } + }) + + test('failing validation shows a previewable fix; apply → re-validate succeeds', async ({ page }, testInfo) => { + test.setTimeout(180_000) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + let draftId: number | undefined + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = uniqueSkillName('authoring-fix') + draftId = await createDraftViaApi(page, namespace.slug, skillName) + + // break the scaffold: drop the required description field + const skillMdRead = waitForDraftFileRead(page, 'SKILL.md', draftId) + await page.goto(`/dashboard/authoring/${draftId}`) + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + const editor = page.getByTestId('file-editor') + await expect(editor).toBeVisible() + await skillMdRead + await editor.fill(brokenSkillMd(skillName)) + const skillMdSave = waitForDraftFileSave(page, draftId) + await page.getByTestId('save-file').click() + expect((await skillMdSave).status()).toBe(200) + + // validate → fails with a finding that carries a suggested fix + await page.getByTestId('start-run').click() + await expect(page.getByTestId('run-status')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('run-status')).toHaveText('Failed', { timeout: 90_000 }) + await expect(page.getByTestId('error-count')).not.toHaveText('0') + const finding = page.locator('[data-testid^="finding-"]', { + hasText: 'FRONTMATTER_FIELD_MISSING', + }).first() + await expect(finding).toBeVisible({ timeout: 15_000 }) + + // preview the suggested patch before applying it + await finding.getByTestId('suggestion-toggle').click() + const diffPreview = finding.locator('pre') + await expect(diffPreview).toBeVisible() + await expect(diffPreview).toContainText('description:') + + // two-step confirm: apply, then watch the finding flip to Applied + await finding.getByTestId('apply-start').click() + await finding.getByTestId('apply-confirm').click() + await expect(finding.getByTestId('finding-status')).toContainText('Applied', { timeout: 15_000 }) + + // one click back to the workbench, re-validate, and the run passes + await page.getByRole('button', { name: 'Fix and re-validate' }).click() + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + await page.getByTestId('start-run').click() + await expect(page.getByTestId('run-status')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('run-status')).toHaveText('Succeeded', { timeout: 90_000 }) + await expect(page.getByTestId('error-count')).toHaveText('0') + } finally { + if (draftId !== undefined) { + await deleteDraftViaApi(page, draftId) + } + await builder.cleanup() + } + }) + + test('uploads a binary asset that shows read-only with byte-accurate metadata', async ({ page }, testInfo) => { + test.setTimeout(120_000) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + let draftId: number | undefined + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = uniqueSkillName('authoring-binary') + draftId = await createDraftViaApi(page, namespace.slug, skillName) + + await page.goto(`/dashboard/authoring/${draftId}`) + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + + await page.getByTestId('upload-file-button').click() + const uploadDialog = page.getByRole('dialog') + await expect(uploadDialog).toBeVisible() + await uploadDialog.getByTestId('upload-file-input').setInputFiles({ + name: 'logo.png', + mimeType: 'image/png', + buffer: PNG_BUFFER, + }) + // choosing a file prefills the target draft path + await expect(uploadDialog.getByTestId('upload-path-input')).toHaveValue('assets/logo.png') + const uploadSave = waitForDraftFileSave(page, draftId) + await uploadDialog.getByTestId('confirm-upload').click() + expect((await uploadSave).status()).toBe(200) + + // the asset appears in the file list and opens the read-only binary panel + await page.getByTestId('draft-file-list').getByRole('button', { name: 'assets/logo.png', exact: true }).click() + const panel = page.getByTestId('binary-file-panel') + await expect(panel).toBeVisible() + await expect(panel).toContainText('assets/logo.png') + await expect(panel).toContainText('image/png') + await expect(panel).toContainText(`${PNG_BUFFER.length} B`) + await expect(panel.getByTestId('replace-file')).toBeVisible() + await expect(panel.getByTestId('delete-file')).toBeVisible() + + // the stored bytes round-trip byte-accurately (sha256 over the API-reported content) + const filesResponse = await page.context().request.get(`/api/web/authoring/drafts/${draftId}/files`) + const files = await filesResponse.json() as ApiEnvelope + const uploaded = files.data.find((file) => file.path === 'assets/logo.png') + expect(uploaded?.size).toBe(PNG_BUFFER.length) + expect(uploaded?.contentType).toBe('image/png') + expect(uploaded?.sha256).toBe(crypto.createHash('sha256').update(PNG_BUFFER).digest('hex')) + } finally { + if (draftId !== undefined) { + await deleteDraftViaApi(page, draftId) + } + await builder.cleanup() + } + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 01fad2c53..2691247fd 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -53,6 +53,15 @@ import type { BatchMemberResponse, SkillSuite, SkillSuiteDraftInput, + AuthoringDraft, + DraftFileSummary, + DraftFileContent, + SaveDraftFileOutcome, + RuntimeBindingInfo, + ValidationRunInfo, + ValidationEventInfo, + ValidationFindingInfo, + SubmitDraftOutcome, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -1682,3 +1691,162 @@ export const notificationApi = { }) }, } + +export const authoringApi = { + async listDrafts(): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts`) + }, + + async createDraft(request: { namespaceSlug: string, name: string, requirement?: string }): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async getDraft(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`) + }, + + async deleteDraft(draftId: number): Promise { + await fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`, { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }) + }, + + async listFiles(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`) + }, + + async readFile(draftId: number, path: string): Promise { + const query = new URLSearchParams({ path }) + return fetchJson( + `${WEB_API_PREFIX}/authoring/drafts/${draftId}/files/content?${query.toString()}`, + ) + }, + + async saveFile( + draftId: number, + request: { path: string, content: string, contentType?: string, expectedRevision?: number, base64?: boolean }, + ): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + path: request.path, + content: request.content, + encoding: request.base64 ? 'base64' : undefined, + contentType: request.contentType, + expectedRevision: request.expectedRevision, + }), + }) + }, + + async deleteFile(draftId: number, path: string, expectedRevision?: number): Promise { + const query = new URLSearchParams({ path }) + if (expectedRevision !== undefined) { + query.set('expectedRevision', String(expectedRevision)) + } + await fetchJson( + `${WEB_API_PREFIX}/authoring/drafts/${draftId}/files?${query.toString()}`, + { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async getRuntimeBinding(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runtime`) + }, + + async saveRuntimeBinding( + draftId: number, + request: { agentType: string, config?: Record, toolAllowlist?: string[], mcpServers?: Record[] }, + ): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runtime`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + agentType: request.agentType, + config: request.config ?? {}, + toolAllowlist: request.toolAllowlist ?? [], + mcpServers: request.mcpServers ?? [], + }), + }) + }, + + async startRun(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async listRuns(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`) + }, + + async getRun(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}`) + }, + + async cancelRun(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/cancel`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async listEvents(runId: number, afterSeq?: number): Promise { + const query = afterSeq === undefined ? '' : `?afterSeq=${afterSeq}` + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/events${query}`) + }, + + async listFindings(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/findings`) + }, + + async applyFix(runId: number, findingId: number): Promise { + return fetchJson( + `${WEB_API_PREFIX}/authoring/runs/${runId}/findings/${findingId}/apply`, + { + method: 'POST', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async dismissFinding(runId: number, findingId: number): Promise { + return fetchJson( + `${WEB_API_PREFIX}/authoring/runs/${runId}/findings/${findingId}/dismiss`, + { + method: 'POST', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async submitDraft(draftId: number, visibility: 'PRIVATE' | 'PUBLIC' = 'PRIVATE'): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/submit`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ visibility }), + }) + }, + + /** Absolute URL for the SSE stream; EventSource resumes from the browser-managed Last-Event-ID. */ + validationStreamUrl(runId: number): string { + return buildApiUrl(`${WEB_API_PREFIX}/authoring/runs/${runId}/events/stream`) + }, +} diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index a972eafc0..a3e9b633e 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -426,6 +426,80 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/authoring/drafts/{draftId}/runtime": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the draft's runtime binding */ + get: operations["getDraftRuntimeBinding"]; + /** Configure the agent runtime, tools, and MCP servers */ + put: operations["saveDraftRuntimeBinding"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts/{draftId}/runtime": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the draft's runtime binding */ + get: operations["getDraftRuntimeBinding_1"]; + /** Configure the agent runtime, tools, and MCP servers */ + put: operations["saveDraftRuntimeBinding_1"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/drafts/{draftId}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List a draft's files with metadata */ + get: operations["listDraftFiles"]; + /** Create or update one draft file */ + put: operations["saveDraftFile"]; + post?: never; + /** Delete one draft file */ + delete: operations["deleteDraftFile"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts/{draftId}/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List a draft's files with metadata */ + get: operations["listDraftFiles_1"]; + /** Create or update one draft file */ + put: operations["saveDraftFile_1"]; + post?: never; + /** Delete one draft file */ + delete: operations["deleteDraftFile_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/tokens/{id}/expiration": { parameters: { query?: never; @@ -2028,6 +2102,214 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/authoring/runs/{runId}/findings/{findingId}/dismiss": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dismiss a finding as not applicable */ + post: operations["dismissFindingFix"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/runs/{runId}/findings/{findingId}/dismiss": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dismiss a finding as not applicable */ + post: operations["dismissFindingFix_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/runs/{runId}/findings/{findingId}/apply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Apply a finding's fix suggestion to the draft */ + post: operations["applyFindingFix"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/runs/{runId}/findings/{findingId}/apply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Apply a finding's fix suggestion to the draft */ + post: operations["applyFindingFix_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/runs/{runId}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request cooperative cancellation of a run */ + post: operations["cancelValidationRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/runs/{runId}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request cooperative cancellation of a run */ + post: operations["cancelValidationRun_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/drafts/{draftId}/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit a validated draft into the publish pipeline */ + post: operations["submitValidatedDraft"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts/{draftId}/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit a validated draft into the publish pipeline */ + post: operations["submitValidatedDraft_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts/{draftId}/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the draft's validation runs */ + get: operations["listValidationRuns"]; + put?: never; + /** Start a validation run for the draft's current revision */ + post: operations["startValidationRun"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/drafts/{draftId}/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the draft's validation runs */ + get: operations["listValidationRuns_1"]; + put?: never; + /** Start a validation run for the draft's current revision */ + post: operations["startValidationRun_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the current user's drafts */ + get: operations["listAuthoringDrafts"]; + put?: never; + /** Create a draft seeded with a SKILL.md scaffold */ + post: operations["createAuthoringDraft"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/drafts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the current user's drafts */ + get: operations["listAuthoringDrafts_1"]; + put?: never; + /** Create a draft seeded with a SKILL.md scaffold */ + post: operations["createAuthoringDraft_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/tokens": { parameters: { query?: never; @@ -4561,14 +4843,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/whoami": { + "/api/web/authoring/runs/{runId}/findings": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["whoami"]; + /** List a run's findings with fix suggestions */ + get: operations["listValidationFindings"]; put?: never; post?: never; delete?: never; @@ -4577,14 +4860,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/skills/{skillId}/versions/{versionId}/security-audit": { + "/api/v1/authoring/runs/{runId}/findings": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["getSecurityAudits"]; + /** List a run's findings with fix suggestions */ + get: operations["listValidationFindings_1"]; put?: never; post?: never; delete?: never; @@ -4593,30 +4877,32 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/skills/{canonicalSlug}": { + "/api/v1/authoring/runs/{runId}/events/stream": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["getSkill"]; + /** Stream run events over SSE; resumes from Last-Event-ID */ + get: operations["streamValidationEvents"]; put?: never; post?: never; - delete: operations["deleteSkill_2"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/search": { + "/api/web/authoring/runs/{runId}/events/stream": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["search_1"]; + /** Stream run events over SSE; resumes from Last-Event-ID */ + get: operations["streamValidationEvents_1"]; put?: never; post?: never; delete?: never; @@ -4625,14 +4911,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/resolve": { + "/api/web/authoring/runs/{runId}/events": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["resolveByQuery"]; + /** List run events after a sequence cursor (polling) */ + get: operations["listValidationEvents"]; put?: never; post?: never; delete?: never; @@ -4641,14 +4928,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/resolve/{canonicalSlug}": { + "/api/v1/authoring/runs/{runId}/events": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["resolve"]; + /** List run events after a sequence cursor (polling) */ + get: operations["listValidationEvents_1"]; put?: never; post?: never; delete?: never; @@ -4657,14 +4945,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/organizations": { + "/api/web/authoring/runs/{runId}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["listOrganizations"]; + /** Get one validation run */ + get: operations["getValidationRun"]; put?: never; post?: never; delete?: never; @@ -4673,14 +4962,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/organizations/{organizationId}": { + "/api/v1/authoring/runs/{runId}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["getOrganization"]; + /** Get one validation run */ + get: operations["getValidationRun_1"]; put?: never; post?: never; delete?: never; @@ -4689,14 +4979,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/organizations/{organizationId}/login-connections": { + "/api/v1/authoring/drafts/{draftId}/files/content": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["listLoginConnections"]; + /** Read one draft file's content */ + get: operations["readDraftFile"]; put?: never; post?: never; delete?: never; @@ -4705,14 +4996,15 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/health": { + "/api/web/authoring/drafts/{draftId}/files/content": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["health"]; + /** Read one draft file's content */ + get: operations["readDraftFile_1"]; put?: never; post?: never; delete?: never; @@ -4721,46 +5013,50 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/download": { + "/api/web/authoring/drafts/{draftId}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["downloadByQuery"]; + /** Get one draft owned by the current user */ + get: operations["getAuthoringDraft"]; put?: never; post?: never; - delete?: never; + /** Delete a draft and its files */ + delete: operations["deleteAuthoringDraft"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/download/{canonicalSlug}": { + "/api/v1/authoring/drafts/{draftId}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["downloadByPath"]; + /** Get one draft owned by the current user */ + get: operations["getAuthoringDraft_1"]; put?: never; post?: never; - delete?: never; + /** Delete a draft and its files */ + delete: operations["deleteAuthoringDraft_1"]; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/v1/auth/providers": { + "/api/v1/whoami": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["providers"]; + get: operations["whoami"]; put?: never; post?: never; delete?: never; @@ -4769,14 +5065,14 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/auth/methods": { + "/api/v1/skills/{skillId}/versions/{versionId}/security-audit": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["methods"]; + get: operations["getSecurityAudits"]; put?: never; post?: never; delete?: never; @@ -4785,17 +5081,209 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/auth/me": { + "/api/v1/skills/{canonicalSlug}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["me"]; + get: operations["getSkill"]; put?: never; post?: never; - delete?: never; + delete: operations["deleteSkill_2"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolveByQuery"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/resolve/{canonicalSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["resolve"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/organizations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listOrganizations"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/organizations/{organizationId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getOrganization"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/organizations/{organizationId}/login-connections": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listLoginConnections"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadByQuery"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/download/{canonicalSlug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["downloadByPath"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["providers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/methods": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["methods"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["me"]; + put?: never; + post?: never; + delete?: never; options?: never; head?: never; patch?: never; @@ -5468,6 +5956,98 @@ export interface components { /** Format: date-time */ updatedAt?: string; }; + RuntimeBindingRequest: { + agentType: string; + config?: { + [key: string]: Record; + }; + toolAllowlist?: string[]; + mcpServers?: { + [key: string]: Record; + }[]; + }; + ApiResponseRuntimeBindingResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["RuntimeBindingResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + RuntimeBindingResponse: { + agentType?: string; + config?: { + [key: string]: Record; + }; + toolAllowlist?: string[]; + mcpServers?: { + [key: string]: Record; + }[]; + /** Format: date-time */ + updatedAt?: string; + }; + SaveDraftFileRequest: { + path: string; + content: string; + encoding?: string; + contentType?: string; + /** Format: int32 */ + expectedRevision?: number; + base64?: boolean; + }; + ApiResponseSaveDraftFileResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SaveDraftFileResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DraftFileResponse: { + /** Format: int64 */ + id?: number; + path?: string; + sha256?: string; + /** Format: int64 */ + size?: number; + contentType?: string; + /** Format: date-time */ + updatedAt?: string; + }; + DraftResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + namespaceId?: number; + name?: string; + requirement?: string; + /** Format: int32 */ + revision?: number; + contentDigest?: string; + validated?: boolean; + /** Format: int32 */ + validatedRevision?: number; + /** Format: int64 */ + validatedRunId?: number; + /** Format: int64 */ + submittedSkillId?: number; + /** Format: int64 */ + submittedVersionId?: number; + /** Format: date-time */ + submittedAt?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + SaveDraftFileResponse: { + draft?: components["schemas"]["DraftResponse"]; + file?: components["schemas"]["DraftFileResponse"]; + created?: boolean; + revisionAdvanced?: boolean; + }; TokenExpirationUpdateRequest: { expiresAt?: string; }; @@ -5962,22 +6542,129 @@ export interface components { createdAt?: string; readAt?: string; }; - TokenCreateRequest: { - name: string; - scopes?: string[]; - expiresAt?: string; - }; - ApiResponseTokenCreateResponse: { + ApiResponseValidationFindingResponse: { /** Format: int32 */ code?: number; msg?: string; - data?: components["schemas"]["TokenCreateResponse"]; + data?: components["schemas"]["ValidationFindingResponse"]; /** Format: date-time */ timestamp?: string; requestId?: string; }; - TokenCreateResponse: { - token?: string; + FilePatch: { + filePath?: string; + oldSha256?: string; + oldValue?: string; + newValue?: string; + }; + FixSuggestion: { + patches?: components["schemas"]["FilePatch"][]; + description?: string; + }; + ValidationFindingResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + runId?: number; + layer?: string; + ruleCode?: string; + severity?: string; + filePath?: string; + location?: string; + message?: string; + suggestion?: components["schemas"]["FixSuggestion"]; + status?: string; + /** Format: int32 */ + appliedRevision?: number; + /** Format: date-time */ + createdAt?: string; + }; + ApiResponseValidationRunResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ValidationRunResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ValidationRunResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + draftId?: number; + /** Format: int32 */ + draftRevision?: number; + status?: string; + cancelRequested?: boolean; + active?: boolean; + terminal?: boolean; + /** Format: int32 */ + errorCount?: number; + /** Format: int32 */ + warningCount?: number; + triggeredBy?: string; + /** Format: date-time */ + startedAt?: string; + /** Format: date-time */ + finishedAt?: string; + /** Format: date-time */ + createdAt?: string; + summary?: { + [key: string]: Record; + }; + }; + SubmitDraftRequest: { + visibility?: string; + platformRoles?: string[]; + }; + ApiResponseSubmitDraftResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SubmitDraftResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SubmitDraftResponse: { + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + versionId?: number; + slug?: string; + version?: string; + }; + CreateDraftRequest: { + namespaceSlug: string; + name: string; + requirement?: string; + }; + ApiResponseDraftResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DraftResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TokenCreateRequest: { + name: string; + scopes?: string[]; + expiresAt?: string; + }; + ApiResponseTokenCreateResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["TokenCreateResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + TokenCreateResponse: { + token?: string; /** Format: int64 */ id?: number; name?: string; @@ -7383,6 +8070,87 @@ export interface components { /** Format: int32 */ size?: number; }; + ApiResponseListValidationFindingResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ValidationFindingResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SseEmitter: { + /** Format: int64 */ + timeout?: number; + }; + ApiResponseListValidationEventResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ValidationEventResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ValidationEventResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + runId?: number; + /** Format: int32 */ + seq?: number; + type?: string; + phase?: string; + payload?: { + [key: string]: Record; + }; + /** Format: date-time */ + createdAt?: string; + }; + ApiResponseListValidationRunResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ValidationRunResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseDraftFileContentResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DraftFileContentResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DraftFileContentResponse: { + path?: string; + sha256?: string; + /** Format: int64 */ + size?: number; + contentType?: string; + content?: string; + }; + ApiResponseListDraftFileResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DraftFileResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ApiResponseListDraftResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DraftResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ClawHubWhoamiResponse: { user?: components["schemas"]["User"]; }; @@ -9236,20 +10004,16 @@ export interface operations { }; }; }; - updateExpiration: { + getDraftRuntimeBinding: { parameters: { query?: never; header?: never; path: { - id: number; + draftId: number; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["TokenExpirationUpdateRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -9257,23 +10021,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseTokenSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; }; }; }; }; - updateUserStatus: { + saveDraftRuntimeBinding: { parameters: { query?: never; header?: never; path: { - userId: string; + draftId: number; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminUserStatusUpdateRequest"]; + "application/json": components["schemas"]["RuntimeBindingRequest"]; }; }; responses: { @@ -9283,25 +10047,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; }; }; }; }; - updateUserRole: { + getDraftRuntimeBinding_1: { parameters: { query?: never; header?: never; path: { - userId: string; + draftId: number; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminUserRoleUpdateRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -9309,24 +10069,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; }; }; }; }; - updateMemberRole_2: { + saveDraftRuntimeBinding_1: { parameters: { query?: never; header?: never; path: { - slug: string; - userId: string; + draftId: number; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["UpdateMemberRoleRequest"]; + "application/json": components["schemas"]["RuntimeBindingRequest"]; }; }; responses: { @@ -9336,25 +10095,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMemberResponse"]; + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; }; }; }; }; - updateLabel: { + listDraftFiles: { parameters: { query?: never; header?: never; path: { - slug: string; + draftId: number; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminLabelUpdateRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -9362,21 +10117,25 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseLabelDefinitionResponse"]; + "*/*": components["schemas"]["ApiResponseListDraftFileResponse"]; }; }; }; }; - deleteLabel: { + saveDraftFile: { parameters: { query?: never; header?: never; path: { - slug: string; + draftId: number; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SaveDraftFileRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -9384,23 +10143,24 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseSaveDraftFileResponse"]; }; }; }; }; - updateSortOrder: { + deleteDraftFile: { parameters: { - query?: never; + query: { + path: string; + expectedRevision?: number; + }; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["LabelSortOrderUpdateRequest"]; + path: { + draftId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -9408,124 +10168,374 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListLabelDefinitionResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - yankSkillSuiteVersion: { + listDraftFiles_1: { parameters: { query?: never; header?: never; path: { - suiteId: number; - versionId: number; + draftId: number; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["SkillSuiteReasonRequest"]; - }; - }; + requestBody?: never; responses: { - /** @description Suite version yanked */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseListDraftFileResponse"]; }; }; }; }; - yankSkillSuiteVersion_1: { + saveDraftFile_1: { parameters: { query?: never; header?: never; path: { - suiteId: number; - versionId: number; + draftId: number; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["SkillSuiteReasonRequest"]; + "application/json": components["schemas"]["SaveDraftFileRequest"]; }; }; responses: { - /** @description Suite version yanked */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseSaveDraftFileResponse"]; }; }; }; }; - submitSkillSuiteReview: { + deleteDraftFile_1: { parameters: { - query?: never; + query: { + path: string; + expectedRevision?: number; + }; header?: never; path: { - suiteId: number; - versionId: number; + draftId: number; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suite draft submitted */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - submitSkillSuiteReview_1: { + updateExpiration: { parameters: { query?: never; header?: never; path: { - suiteId: number; - versionId: number; + id: number; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["TokenExpirationUpdateRequest"]; + }; + }; responses: { - /** @description Suite draft submitted */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseTokenSummaryResponse"]; }; }; }; }; - reopenSkillSuiteDraft: { + updateUserStatus: { parameters: { query?: never; header?: never; path: { - suiteId: number; - versionId: number; + userId: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserStatusUpdateRequest"]; + }; + }; responses: { - /** @description Suite draft reopened */ + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + updateUserRole: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserRoleUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + }; + }; + }; + }; + updateMemberRole_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateMemberRoleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + }; + }; + updateLabel: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminLabelUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseLabelDefinitionResponse"]; + }; + }; + }; + }; + deleteLabel: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + updateSortOrder: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LabelSortOrderUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListLabelDefinitionResponse"]; + }; + }; + }; + }; + yankSkillSuiteVersion: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteReasonRequest"]; + }; + }; + responses: { + /** @description Suite version yanked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + yankSkillSuiteVersion_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillSuiteReasonRequest"]; + }; + }; + responses: { + /** @description Suite version yanked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + submitSkillSuiteReview: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite draft submitted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + submitSkillSuiteReview_1: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite draft submitted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + reopenSkillSuiteDraft: { + parameters: { + query?: never; + header?: never; + path: { + suiteId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite draft reopened */ 200: { headers: { [name: string]: unknown; @@ -11911,14 +12921,14 @@ export interface operations { }; }; }; - list_4: { + dismissFindingFix: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + runId: number; + findingId: number; + }; cookie?: never; }; requestBody?: never; @@ -11929,23 +12939,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseTokenSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; }; }; }; }; - create: { + dismissFindingFix_1: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["TokenCreateRequest"]; + path: { + runId: number; + findingId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -11953,17 +12962,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseTokenCreateResponse"]; + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; }; }; }; }; - starSkill_2: { + applyFindingFix: { parameters: { query?: never; header?: never; path: { - canonicalSlug: string; + runId: number; + findingId: number; }; cookie?: never; }; @@ -11975,17 +12985,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubStarResponse"]; + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; }; }; }; }; - unstarSkill_2: { + applyFindingFix_1: { parameters: { query?: never; header?: never; path: { - canonicalSlug: string; + runId: number; + findingId: number; }; cookie?: never; }; @@ -11997,22 +13008,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubUnstarResponse"]; + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; }; }; }; }; - listSkills: { + cancelValidationRun: { parameters: { - query?: { - page?: number; - limit?: number; - sort?: string; - /** @description Optional response expansions. Supported value: labels */ - include?: string[]; - }; + query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -12023,20 +13030,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubSkillListResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - publishSkill: { + cancelValidationRun_1: { parameters: { - query: { - payload: string; - files: string[]; - confirmWarnings?: boolean; - }; + query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -12047,22 +13052,25 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubPublishResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - retrySecurityScan: { + submitValidatedDraft: { parameters: { query?: never; header?: never; path: { - skillId: number; - versionId: number; + draftId: number; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["SubmitDraftRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -12070,21 +13078,25 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + "*/*": components["schemas"]["ApiResponseSubmitDraftResponse"]; }; }; }; }; - undeleteSkill: { + submitValidatedDraft_1: { parameters: { query?: never; header?: never; path: { - canonicalSlug: string; + draftId: number; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["SubmitDraftRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -12092,29 +13104,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubDeleteResponse"]; + "*/*": components["schemas"]["ApiResponseSubmitDraftResponse"]; }; }; }; }; - publish_2: { + listValidationRuns: { parameters: { - query: { - namespace: string; - confirmWarnings?: boolean; - }; + query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": { - /** Format: binary */ - file: string; - }; + path: { + draftId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12122,23 +13126,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ClawHubPublishResponse"]; + "*/*": components["schemas"]["ApiResponseListValidationRunResponse"]; }; }; }; }; - authorizeDevice: { + startValidationRun: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AuthorizeRequest"]; + path: { + draftId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12146,23 +13148,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - bootstrapSession: { + listValidationRuns_1: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SessionBootstrapRequest"]; + path: { + draftId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12170,23 +13170,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + "*/*": components["schemas"]["ApiResponseListValidationRunResponse"]; }; }; }; }; - register: { + startValidationRun_1: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["LocalRegisterRequest"]; + path: { + draftId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12194,23 +13192,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - requestPasswordReset: { + listAuthoringDrafts: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["PasswordResetRequestDto"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12218,12 +13212,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseVoid"]; + "*/*": components["schemas"]["ApiResponseListDraftResponse"]; }; }; }; }; - confirmPasswordReset: { + createAuthoringDraft: { parameters: { query?: never; header?: never; @@ -12232,7 +13226,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["PasswordResetConfirmRequest"]; + "application/json": components["schemas"]["CreateDraftRequest"]; }; }; responses: { @@ -12242,23 +13236,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseVoid"]; + "*/*": components["schemas"]["ApiResponseDraftResponse"]; }; }; }; }; - login: { + listAuthoringDrafts_1: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["LocalLoginRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12266,12 +13256,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + "*/*": components["schemas"]["ApiResponseListDraftResponse"]; }; }; }; }; - changePassword: { + createAuthoringDraft_1: { parameters: { query?: never; header?: never; @@ -12280,7 +13270,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["ChangePasswordRequest"]; + "application/json": components["schemas"]["CreateDraftRequest"]; }; }; responses: { @@ -12290,23 +13280,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseVoid"]; + "*/*": components["schemas"]["ApiResponseDraftResponse"]; }; }; }; }; - directLogin: { + list_4: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["DirectLoginRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12314,12 +13303,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseTokenSummaryResponse"]; }; }; }; }; - pollToken: { + create: { parameters: { query?: never; header?: never; @@ -12328,7 +13317,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["TokenRequest"]; + "application/json": components["schemas"]["TokenCreateRequest"]; }; }; responses: { @@ -12338,16 +13327,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseDeviceTokenResponse"]; + "*/*": components["schemas"]["ApiResponseTokenCreateResponse"]; }; }; }; }; - requestDeviceCode: { + starSkill_2: { parameters: { query?: never; header?: never; - path?: never; + path: { + canonicalSlug: string; + }; cookie?: never; }; requestBody?: never; @@ -12358,17 +13349,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseDeviceCodeResponse"]; + "*/*": components["schemas"]["ClawHubStarResponse"]; }; }; }; }; - triggerPasswordReset: { + unstarSkill_2: { parameters: { query?: never; header?: never; path: { - userId: string; + canonicalSlug: string; }; cookie?: never; }; @@ -12380,18 +13371,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseVoid"]; + "*/*": components["schemas"]["ClawHubUnstarResponse"]; }; }; }; }; - enableUser: { + listSkills: { parameters: { - query?: never; - header?: never; - path: { - userId: string; + query?: { + page?: number; + limit?: number; + sort?: string; + /** @description Optional response expansions. Supported value: labels */ + include?: string[]; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -12402,18 +13397,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + "*/*": components["schemas"]["ClawHubSkillListResponse"]; }; }; }; }; - disableUser: { + publishSkill: { parameters: { - query?: never; - header?: never; - path: { - userId: string; + query: { + payload: string; + files: string[]; + confirmWarnings?: boolean; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -12424,17 +13421,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + "*/*": components["schemas"]["ClawHubPublishResponse"]; }; }; }; }; - approveUser: { + retrySecurityScan: { parameters: { query?: never; header?: never; path: { - userId: string; + skillId: number; + versionId: number; }; cookie?: never; }; @@ -12446,17 +13444,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; }; }; }; }; - unhideSkill: { + undeleteSkill: { parameters: { query?: never; header?: never; path: { - skillId: number; + canonicalSlug: string; }; cookie?: never; }; @@ -12468,23 +13466,27 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + "*/*": components["schemas"]["ClawHubDeleteResponse"]; }; }; }; }; - hideSkill: { + publish_2: { parameters: { - query?: never; - header?: never; - path: { - skillId: number; + query: { + namespace: string; + confirmWarnings?: boolean; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["AdminSkillActionRequest"]; + "application/json": { + /** Format: binary */ + file: string; + }; }; }; responses: { @@ -12494,23 +13496,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + "*/*": components["schemas"]["ClawHubPublishResponse"]; }; }; }; }; - yankVersion_2: { + authorizeDevice: { parameters: { query?: never; header?: never; - path: { - versionId: number; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["AdminSkillActionRequest"]; + "application/json": components["schemas"]["AuthorizeRequest"]; }; }; responses: { @@ -12520,21 +13520,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; + "*/*": components["schemas"]["ApiResponseMessageResponse"]; }; }; }; }; - restore: { + bootstrapSession: { parameters: { query?: never; header?: never; - path: { - reviewId: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["SessionBootstrapRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -12542,23 +13544,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillReviewResponse"]; + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; }; }; }; }; - hide: { + register: { parameters: { query?: never; header?: never; - path: { - reviewId: number; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["SkillReviewModerationRequest"]; + "application/json": components["schemas"]["LocalRegisterRequest"]; }; }; responses: { @@ -12568,23 +13568,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillReviewResponse"]; + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; }; }; }; }; - resolveReport: { + requestPasswordReset: { parameters: { query?: never; header?: never; - path: { - reportId: number; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["AdminSkillReportActionRequest"]; + "application/json": components["schemas"]["PasswordResetRequestDto"]; }; }; responses: { @@ -12594,23 +13592,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillReportMutationResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - dismissReport: { + confirmPasswordReset: { parameters: { query?: never; header?: never; - path: { - reportId: number; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["AdminSkillReportActionRequest"]; + "application/json": components["schemas"]["PasswordResetConfirmRequest"]; }; }; responses: { @@ -12620,19 +13616,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillReportMutationResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - rebuildAll: { + login: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["LocalLoginRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -12640,23 +13640,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseVoid"]; + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; }; }; }; }; - reject_2: { + changePassword: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["ProfileReviewRejectRequest"]; + "application/json": components["schemas"]["ChangePasswordRequest"]; }; }; responses: { @@ -12666,21 +13664,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseProfileReviewMutationResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - approve_2: { + directLogin: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["DirectLoginRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -12688,12 +13688,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseProfileReviewMutationResponse"]; + "*/*": components["schemas"]["ApiResponseAuthMeResponse"]; }; }; }; }; - createOrganization: { + pollToken: { parameters: { query?: never; header?: never; @@ -12702,7 +13702,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["OrganizationCreateRequest"]; + "application/json": components["schemas"]["TokenRequest"]; }; }; responses: { @@ -12712,18 +13712,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseOrganizationCreateResponse"]; + "*/*": components["schemas"]["ApiResponseDeviceTokenResponse"]; }; }; }; }; - unfreezeNamespace_2: { + requestDeviceCode: { parameters: { query?: never; header?: never; - path: { - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -12734,25 +13732,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + "*/*": components["schemas"]["ApiResponseDeviceCodeResponse"]; }; }; }; }; - transferOwnership_2: { + triggerPasswordReset: { parameters: { query?: never; header?: never; path: { - slug: string; + userId: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["TransferOwnershipRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12760,17 +13754,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - restoreNamespace_2: { + enableUser: { parameters: { query?: never; header?: never; path: { - slug: string; + userId: string; }; cookie?: never; }; @@ -12782,20 +13776,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; }; }; }; }; - listMembers_2: { + disableUser: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; path: { - slug: string; + userId: string; }; cookie?: never; }; @@ -12807,25 +13798,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseMemberResponse"]; + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; }; }; }; }; - addMember_2: { + approveUser: { parameters: { query?: never; header?: never; path: { - slug: string; + userId: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["MemberRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12833,25 +13820,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMemberResponse"]; + "*/*": components["schemas"]["ApiResponseAdminUserMutationResponse"]; }; }; }; }; - batchAddMembers_2: { + unhideSkill: { parameters: { query?: never; header?: never; path: { - slug: string; + skillId: number; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["BatchMemberRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -12859,23 +13842,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseBatchMemberResponse"]; + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; }; }; }; }; - freezeNamespace_2: { + hideSkill: { parameters: { query?: never; header?: never; path: { - slug: string; + skillId: number; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["NamespaceLifecycleRequest"]; + "application/json": components["schemas"]["AdminSkillActionRequest"]; }; }; responses: { @@ -12885,23 +13868,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; }; }; }; }; - archiveNamespace_2: { + yankVersion_2: { parameters: { query?: never; header?: never; path: { - slug: string; + versionId: number; }; cookie?: never; }; requestBody?: { content: { - "application/json": components["schemas"]["NamespaceLifecycleRequest"]; + "application/json": components["schemas"]["AdminSkillActionRequest"]; }; }; responses: { @@ -12911,16 +13894,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + "*/*": components["schemas"]["ApiResponseAdminSkillMutationResponse"]; }; }; }; }; - listLabels_2: { + restore: { parameters: { query?: never; header?: never; - path?: never; + path: { + reviewId: number; + }; cookie?: never; }; requestBody?: never; @@ -12931,21 +13916,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListLabelDefinitionResponse"]; + "*/*": components["schemas"]["ApiResponseSkillReviewResponse"]; }; }; }; }; - createLabel: { + hide: { parameters: { query?: never; header?: never; - path?: never; + path: { + reviewId: number; + }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["AdminLabelCreateRequest"]; + "application/json": components["schemas"]["SkillReviewModerationRequest"]; }; }; responses: { @@ -12955,21 +13942,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseLabelDefinitionResponse"]; + "*/*": components["schemas"]["ApiResponseSkillReviewResponse"]; }; }; }; }; - verify: { + resolveReport: { parameters: { query?: never; header?: never; - path?: never; + path: { + reportId: number; + }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["MergeVerifyRequest"]; + "application/json": components["schemas"]["AdminSkillReportActionRequest"]; }; }; responses: { @@ -12979,21 +13968,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseSkillReportMutationResponse"]; }; }; }; }; - initiate: { + dismissReport: { parameters: { query?: never; header?: never; - path?: never; + path: { + reportId: number; + }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["MergeInitiateRequest"]; + "application/json": components["schemas"]["AdminSkillReportActionRequest"]; }; }; responses: { @@ -13003,23 +13994,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMergeInitiateResponse"]; + "*/*": components["schemas"]["ApiResponseSkillReportMutationResponse"]; }; }; }; }; - confirm: { + rebuildAll: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ConfirmMergeRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -13027,21 +14014,23 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - cancel: { + reject_2: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CancelMergeRequest"]; + "application/json": components["schemas"]["ProfileReviewRejectRequest"]; }; }; responses: { @@ -13051,29 +14040,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseProfileReviewMutationResponse"]; }; }; }; }; - publish_3: { + approve_2: { parameters: { query?: never; header?: never; path: { - namespace: string; + id: number; }; cookie?: never; }; - requestBody?: { - content: { - "multipart/form-data": { - /** Format: binary */ - file: string; - visibility?: string; - }; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -13081,27 +14062,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseCliPublishResponse"]; + "*/*": components["schemas"]["ApiResponseProfileReviewMutationResponse"]; }; }; }; }; - validatePublish: { + createOrganization: { parameters: { query?: never; header?: never; - path: { - namespace: string; - }; + path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "multipart/form-data": { - /** Format: binary */ - file: string; - visibility?: string; - }; + "application/json": components["schemas"]["OrganizationCreateRequest"]; }; }; responses: { @@ -13111,16 +14086,415 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseCliDryRunResponse"]; + "*/*": components["schemas"]["ApiResponseOrganizationCreateResponse"]; }; }; }; }; - getProfile: { + unfreezeNamespace_2: { parameters: { query?: never; header?: never; - path?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; + transferOwnership_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnershipRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + restoreNamespace_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; + listMembers_2: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMemberResponse"]; + }; + }; + }; + }; + addMember_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + }; + }; + batchAddMembers_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchMemberRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseBatchMemberResponse"]; + }; + }; + }; + }; + freezeNamespace_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceLifecycleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; + archiveNamespace_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceLifecycleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; + listLabels_2: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListLabelDefinitionResponse"]; + }; + }; + }; + }; + createLabel: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminLabelCreateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseLabelDefinitionResponse"]; + }; + }; + }; + }; + verify: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MergeVerifyRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + initiate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MergeInitiateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMergeInitiateResponse"]; + }; + }; + }; + }; + confirm: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmMergeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + cancel: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CancelMergeRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + publish_3: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + visibility?: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCliPublishResponse"]; + }; + }; + }; + }; + validatePublish: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + visibility?: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseCliDryRunResponse"]; + }; + }; + }; + }; + getProfile: { + parameters: { + query?: never; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -13136,18 +14510,300 @@ export interface operations { }; }; }; - updateProfile: { + updateProfile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateProfileRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseUpdateProfileResponse"]; + }; + }; + }; + }; + listSkillSuiteVersions: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version history returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteVersionSummaryResponse"]; + }; + }; + }; + }; + listSkillSuiteVersions_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version history returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteVersionSummaryResponse"]; + }; + }; + }; + }; + listSkillSuiteLabels: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + }; + }; + }; + }; + listSkillSuiteLabels_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + }; + }; + }; + }; + getSkillSuite: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; + getSkillSuite_1: { + parameters: { + query?: { + version?: string; + }; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Suite version returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + }; + }; + }; + }; + searchSkillSuiteMemberCandidates: { + parameters: { + query: { + suiteNamespace: string; + visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + q?: string; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Eligible member candidates returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteMemberCandidateResponse"]; + }; + }; + }; + }; + searchSkillSuiteMemberCandidates_1: { + parameters: { + query: { + suiteNamespace: string; + visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; + q?: string; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Eligible member candidates returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListSkillSuiteMemberCandidateResponse"]; + }; + }; + }; + }; + getSkillSuiteBundleOperation: { + parameters: { + query?: never; + header?: never; + path: { + operationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationDetailResponse"]; + }; + }; + }; + }; + getSkillSuiteBundleOperation_1: { parameters: { query?: never; header?: never; + path: { + operationId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationDetailResponse"]; + }; + }; + }; + }; + listMySkillSuiteBundleOperations: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateProfileRequest"]; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationPageResponse"]; + }; + }; + }; + }; + listMySkillSuiteBundleOperations_1: { + parameters: { + query?: { + page?: number; + size?: number; }; + header?: never; + path?: never; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -13155,64 +14811,120 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseUpdateProfileResponse"]; + "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationPageResponse"]; }; }; }; }; - listSkillSuiteVersions: { + listActiveSkillSuiteBundleOperations: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; - path: { - namespace: string; - slug: string; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteBundleOperationSummaryResponse"]; + }; + }; + }; + }; + listActiveSkillSuiteBundleOperations_1: { + parameters: { + query?: { + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Suite version history returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillSuiteVersionSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteBundleOperationSummaryResponse"]; }; }; }; }; - listSkillSuiteVersions_1: { + search: { parameters: { - query?: never; + query?: { + q?: string; + namespace?: string; + label?: string[]; + /** @description Optional response expansions. Supported value: labels */ + include?: string[]; + sort?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSearchResponse"]; + }; + }; + }; + }; + list: { + parameters: { + query?: { + page?: number; + size?: number; + }; header?: never; path: { - namespace: string; - slug: string; + skillId: number; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suite version history returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillSuiteVersionSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"]; }; }; }; }; - listSkillSuiteLabels: { + list_1: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; path: { - namespace: string; - slug: string; + skillId: number; }; cookie?: never; }; @@ -13224,18 +14936,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"]; }; }; }; }; - listSkillSuiteLabels_1: { + listFiles: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; + version: string; }; cookie?: never; }; @@ -13247,117 +14960,119 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; }; }; }; }; - getSkillSuite: { + listFiles_1: { parameters: { - query?: { - version?: string; - }; + query?: never; header?: never; path: { namespace: string; slug: string; + version: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suite version returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; }; }; }; }; - getSkillSuite_1: { + getFileContent: { parameters: { - query?: { - version?: string; + query: { + path: string; }; header?: never; path: { namespace: string; slug: string; + version: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Suite version returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteResponse"]; + "*/*": string; }; }; }; }; - searchSkillSuiteMemberCandidates: { + getFileContent_1: { parameters: { query: { - suiteNamespace: string; - visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; - q?: string; - size?: number; + path: string; }; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + version: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Eligible member candidates returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillSuiteMemberCandidateResponse"]; + "*/*": string; }; }; }; }; - searchSkillSuiteMemberCandidates_1: { + downloadVersion: { parameters: { - query: { - suiteNamespace: string; - visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE"; - q?: string; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + version: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description Eligible member candidates returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillSuiteMemberCandidateResponse"]; + "*/*": string; }; }; }; }; - getSkillSuiteBundleOperation: { + downloadVersion_1: { parameters: { query?: never; header?: never; path: { - operationId: string; + namespace: string; + slug: string; + version: string; }; cookie?: never; }; @@ -13369,17 +15084,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationDetailResponse"]; + "*/*": string; }; }; }; }; - getSkillSuiteBundleOperation_1: { + getVersionDetail: { parameters: { query?: never; header?: never; path: { - operationId: string; + namespace: string; + slug: string; + version: string; }; cookie?: never; }; @@ -13391,19 +15108,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationDetailResponse"]; + "*/*": components["schemas"]["ApiResponseSkillVersionDetailResponse"]; }; }; }; }; - listMySkillSuiteBundleOperations: { + deleteVersion: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + version: string; + }; cookie?: never; }; requestBody?: never; @@ -13414,19 +15132,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationPageResponse"]; + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; }; }; }; }; - listMySkillSuiteBundleOperations_1: { + getVersionDetail_1: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + version: string; + }; cookie?: never; }; requestBody?: never; @@ -13437,19 +15156,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillSuiteBundleOperationPageResponse"]; + "*/*": components["schemas"]["ApiResponseSkillVersionDetailResponse"]; }; }; }; }; - listActiveSkillSuiteBundleOperations: { + deleteVersion_1: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + version: string; + }; cookie?: never; }; requestBody?: never; @@ -13460,19 +15180,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteBundleOperationSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; }; }; }; }; - listActiveSkillSuiteBundleOperations_1: { + compareVersions: { parameters: { - query?: { - page?: number; - size?: number; + query: { + from: string; + to: string; }; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + }; cookie?: never; }; requestBody?: never; @@ -13483,25 +15206,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteBundleOperationSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"]; }; }; }; }; - search: { + compareVersions_1: { parameters: { - query?: { - q?: string; - namespace?: string; - label?: string[]; - /** @description Optional response expansions. Supported value: labels */ - include?: string[]; - sort?: string; - page?: number; - size?: number; + query: { + from: string; + to: string; }; header?: never; - path?: never; + path: { + namespace: string; + slug: string; + }; cookie?: never; }; requestBody?: never; @@ -13512,12 +15232,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSearchResponse"]; + "*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"]; }; }; }; }; - list: { + listVersions: { parameters: { query?: { page?: number; @@ -13525,7 +15245,8 @@ export interface operations { }; header?: never; path: { - skillId: number; + namespace: string; + slug: string; }; cookie?: never; }; @@ -13537,12 +15258,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillVersionResponse"]; }; }; }; }; - list_1: { + listVersions_1: { parameters: { query?: { page?: number; @@ -13550,7 +15271,8 @@ export interface operations { }; header?: never; path: { - skillId: number; + namespace: string; + slug: string; }; cookie?: never; }; @@ -13562,19 +15284,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillVersionResponse"]; }; }; }; }; - listFiles: { + listFilesByTag: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13591,14 +15313,14 @@ export interface operations { }; }; }; - listFiles_1: { + listFilesByTag_1: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13615,7 +15337,7 @@ export interface operations { }; }; }; - getFileContent: { + getFileContentByTag: { parameters: { query: { path: string; @@ -13624,7 +15346,7 @@ export interface operations { path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13641,7 +15363,7 @@ export interface operations { }; }; }; - getFileContent_1: { + getFileContentByTag_1: { parameters: { query: { path: string; @@ -13650,7 +15372,7 @@ export interface operations { path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13667,14 +15389,14 @@ export interface operations { }; }; }; - downloadVersion: { + downloadByTag: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13691,14 +15413,14 @@ export interface operations { }; }; }; - downloadVersion_1: { + downloadByTag_1: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; + tagName: string; }; cookie?: never; }; @@ -13715,14 +15437,13 @@ export interface operations { }; }; }; - getVersionDetail: { + listTags: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; }; cookie?: never; }; @@ -13734,19 +15455,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillVersionDetailResponse"]; + "*/*": components["schemas"]["ApiResponseListTagResponse"]; }; }; }; }; - deleteVersion: { + listTags_1: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - version: string; }; cookie?: never; }; @@ -13758,19 +15478,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + "*/*": components["schemas"]["ApiResponseListTagResponse"]; }; }; }; }; - getVersionDetail_1: { + listSuiteMemberships: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; path: { namespace: string; slug: string; - version: string; }; cookie?: never; }; @@ -13782,19 +15504,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillVersionDetailResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteReferenceResponse"]; }; }; }; }; - deleteVersion_1: { + listSuiteMemberships_1: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; path: { namespace: string; slug: string; - version: string; }; cookie?: never; }; @@ -13806,16 +15530,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteReferenceResponse"]; }; }; }; }; - compareVersions: { + resolveVersion: { parameters: { - query: { - from: string; - to: string; + query?: { + version?: string; + tag?: string; + hash?: string; }; header?: never; path: { @@ -13832,16 +15557,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"]; + "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; }; }; }; }; - compareVersions_1: { + resolveVersion_1: { parameters: { - query: { - from: string; - to: string; + query?: { + version?: string; + tag?: string; + hash?: string; }; header?: never; path: { @@ -13858,17 +15584,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillVersionCompareResponse"]; + "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; }; }; }; }; - listVersions: { + listLabels: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; path: { namespace: string; @@ -13884,17 +15607,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillVersionResponse"]; + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; }; }; }; }; - listVersions_1: { + listLabels_1: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; path: { namespace: string; @@ -13910,19 +15630,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillVersionResponse"]; + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; }; }; }; }; - listFilesByTag: { + downloadLatest: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -13934,19 +15653,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; + "*/*": string; }; }; }; }; - listFilesByTag_1: { + downloadLatest_1: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -13958,21 +15676,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillFileResponse"]; + "*/*": string; }; }; }; }; - getFileContentByTag: { + getSkillDetail: { parameters: { - query: { - path: string; - }; + query?: never; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -13984,21 +15699,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseSkillDetailResponse"]; }; }; }; }; - getFileContentByTag_1: { + deleteSkill_1: { parameters: { - query: { - path: string; + query?: { + ownerId?: string; }; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -14010,19 +15724,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseSkillDeleteResponse"]; }; }; }; }; - downloadByTag: { + getSkillDetail_1: { parameters: { query?: never; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -14034,19 +15747,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseSkillDetailResponse"]; }; }; }; }; - downloadByTag_1: { + deleteSkill: { parameters: { - query?: never; + query?: { + ownerId?: string; + }; header?: never; path: { namespace: string; slug: string; - tagName: string; }; cookie?: never; }; @@ -14058,18 +15772,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseSkillDeleteResponse"]; }; }; }; }; - listTags: { + getReviewSkillDetail: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -14081,18 +15794,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListTagResponse"]; + "*/*": components["schemas"]["ApiResponseReviewSkillDetailResponse"]; }; }; }; }; - listTags_1: { + getReviewSkillDetail_1: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -14104,21 +15816,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListTagResponse"]; + "*/*": components["schemas"]["ApiResponseReviewSkillDetailResponse"]; }; }; }; }; - listSuiteMemberships: { + getReviewFile: { parameters: { - query?: { - page?: number; - size?: number; + query: { + path: string; }; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -14130,21 +15840,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteReferenceResponse"]; + "*/*": string; }; }; }; }; - listSuiteMemberships_1: { + getReviewFile_1: { parameters: { - query?: { - page?: number; - size?: number; + query: { + path: string; }; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -14156,22 +15864,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSuiteReferenceResponse"]; + "*/*": string; }; }; }; }; - resolveVersion: { + downloadReviewVersion: { parameters: { - query?: { - version?: string; - tag?: string; - hash?: string; - }; + query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -14183,22 +15886,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; + "*/*": string; }; }; }; }; - resolveVersion_1: { + downloadReviewVersion_1: { parameters: { - query?: { - version?: string; - tag?: string; - hash?: string; - }; + query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -14210,18 +15908,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseResolveVersionResponse"]; + "*/*": string; }; }; }; }; - listLabels: { + listReviewAttempts: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -14233,18 +15930,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; }; }; }; }; - listLabels_1: { + listReviewAttempts_1: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -14256,18 +15952,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; }; }; }; }; - downloadLatest: { + getReviewDetail: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -14279,18 +15974,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; }; }; }; }; - downloadLatest_1: { + getReviewDetail_1: { parameters: { query?: never; header?: never; path: { - namespace: string; - slug: string; + id: number; }; cookie?: never; }; @@ -14302,19 +15996,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; }; }; }; }; - getSkillDetail: { + listPendingReviews: { parameters: { - query?: never; - header?: never; - path: { - namespace: string; - slug: string; + query: { + namespaceId: number; + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -14325,21 +16020,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillDetailResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; }; }; }; }; - deleteSkill_1: { + listPendingReviews_1: { parameters: { - query?: { - ownerId?: string; + query: { + namespaceId: number; + page?: number; + size?: number; }; header?: never; - path: { - namespace: string; - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -14350,19 +16044,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillDeleteResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; }; }; }; }; - getSkillDetail_1: { + listMySubmissions: { parameters: { - query?: never; - header?: never; - path: { - namespace: string; - slug: string; + query?: { + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -14373,21 +16067,19 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillDetailResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; }; }; }; }; - deleteSkill: { + listMySubmissions_1: { parameters: { query?: { - ownerId?: string; + page?: number; + size?: number; }; header?: never; - path: { - namespace: string; - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -14398,12 +16090,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseSkillDeleteResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; }; }; }; }; - getReviewSkillDetail: { + listMyAttempts: { parameters: { query?: never; header?: never; @@ -14420,12 +16112,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewSkillDetailResponse"]; + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; }; }; }; }; - getReviewSkillDetail_1: { + listMyAttempts_1: { parameters: { query?: never; header?: never; @@ -14442,20 +16134,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewSkillDetailResponse"]; + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; }; }; }; }; - getReviewFile: { + listMyProgress: { parameters: { - query: { - path: string; + query?: { + subjectType?: string; + status?: string; + q?: string; + page?: number; + size?: number; }; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -14466,20 +16160,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; }; }; }; }; - getReviewFile_1: { + listMyProgress_1: { parameters: { - query: { - path: string; + query?: { + subjectType?: string; + status?: string; + q?: string; + page?: number; + size?: number; }; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -14490,56 +16186,68 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; }; }; }; }; - downloadReviewVersion: { + searchResources: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query?: { + q?: string; + namespace?: string; + resourceType?: string; + sort?: string; + page?: number; + size?: number; + label?: string[]; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description Resource page returned */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseResourceSearchResponse"]; }; }; }; }; - downloadReviewVersion_1: { + searchResources_1: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query?: { + q?: string; + namespace?: string; + resourceType?: string; + sort?: string; + page?: number; + size?: number; + label?: string[]; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description Resource page returned */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": string; + "*/*": components["schemas"]["ApiResponseResourceSearchResponse"]; }; }; }; }; - listReviewAttempts: { + getPromotionDetail: { parameters: { query?: never; header?: never; @@ -14556,12 +16264,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; }; }; }; }; - listReviewAttempts_1: { + getPromotionDetail_1: { parameters: { query?: never; header?: never; @@ -14578,17 +16286,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; }; }; }; }; - getReviewDetail: { + getPromotionSourceState: { parameters: { query?: never; header?: never; path: { - id: number; + sourceSkillId: number; }; cookie?: never; }; @@ -14600,17 +16308,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePromotionState"]; }; }; }; }; - getReviewDetail_1: { + getPromotionSourceState_1: { parameters: { query?: never; header?: never; path: { - id: number; + sourceSkillId: number; }; cookie?: never; }; @@ -14622,15 +16330,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePromotionState"]; }; }; }; }; - listPendingReviews: { + listPendingPromotions: { parameters: { - query: { - namespaceId: number; + query?: { page?: number; size?: number; }; @@ -14646,15 +16353,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; }; }; }; }; - listPendingReviews_1: { + listPendingPromotions_1: { parameters: { - query: { - namespaceId: number; + query?: { page?: number; size?: number; }; @@ -14670,19 +16376,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; }; }; }; }; - listMySubmissions: { + get: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody?: never; @@ -14693,19 +16398,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; }; }; }; }; - listMySubmissions_1: { + get_1: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody?: never; @@ -14716,17 +16420,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; }; }; }; }; - listMyAttempts: { + history: { parameters: { query?: never; header?: never; path: { - id: number; + sourceSkillId: number; }; cookie?: never; }; @@ -14738,17 +16442,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; }; }; }; }; - listMyAttempts_1: { + history_1: { parameters: { query?: never; header?: never; path: { - id: number; + sourceSkillId: number; }; cookie?: never; }; @@ -14760,20 +16464,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; }; }; }; }; - listMyProgress: { + pending: { parameters: { - query?: { - subjectType?: string; - status?: string; - q?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -14786,20 +16484,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; + "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; }; }; }; }; - listMyProgress_1: { + pending_1: { parameters: { - query?: { - subjectType?: string; - status?: string; - q?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -14812,21 +16504,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; + "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; }; }; }; }; - searchResources: { + reviewedHistory: { parameters: { query?: { - q?: string; - namespace?: string; - resourceType?: string; - sort?: string; page?: number; size?: number; - label?: string[]; }; header?: never; path?: never; @@ -14834,27 +16521,22 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Resource page returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseResourceSearchResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponsePromotionRevocationResponse"]; }; }; }; }; - searchResources_1: { + reviewedHistory_1: { parameters: { query?: { - q?: string; - namespace?: string; - resourceType?: string; - sort?: string; page?: number; size?: number; - label?: string[]; }; header?: never; path?: never; @@ -14862,24 +16544,22 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Resource page returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseResourceSearchResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponsePromotionRevocationResponse"]; }; }; }; }; - getPromotionDetail: { + unreadCount: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -14890,18 +16570,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + "*/*": components["schemas"]["ApiResponseMapStringLong"]; }; }; }; }; - getPromotionDetail_1: { + unreadCount_1: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -14912,18 +16590,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePromotionResponseDto"]; + "*/*": components["schemas"]["ApiResponseMapStringLong"]; }; }; }; }; - getPromotionSourceState: { + list_2: { parameters: { - query?: never; - header?: never; - path: { - sourceSkillId: number; + query?: { + category?: string; + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -14934,18 +16614,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePromotionState"]; + "*/*": components["schemas"]["ApiResponsePageResponseNotificationResponse"]; }; }; }; }; - getPromotionSourceState_1: { + list_3: { parameters: { - query?: never; - header?: never; - path: { - sourceSkillId: number; + query?: { + category?: string; + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -14956,19 +16638,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePromotionState"]; + "*/*": components["schemas"]["ApiResponsePageResponseNotificationResponse"]; }; }; }; }; - listPendingPromotions: { + searchMemberCandidates: { parameters: { - query?: { - page?: number; + query: { + search: string; size?: number; }; header?: never; - path?: never; + path: { + slug: string; + }; cookie?: never; }; requestBody?: never; @@ -14979,19 +16663,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; + "*/*": components["schemas"]["ApiResponseListNamespaceCandidateUserResponse"]; }; }; }; }; - listPendingPromotions_1: { + searchMemberCandidates_1: { parameters: { - query?: { - page?: number; + query: { + search: string; size?: number; }; header?: never; - path?: never; + path: { + slug: string; + }; cookie?: never; }; requestBody?: never; @@ -15002,18 +16688,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponsePromotionResponseDto"]; + "*/*": components["schemas"]["ApiResponseListNamespaceCandidateUserResponse"]; }; }; }; }; - get: { + listMySkillSuiteWorkspace: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query?: { + q?: string; + state?: string; + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -15024,18 +16713,21 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + "*/*": components["schemas"]["ApiResponseMySkillSuiteWorkspaceResponse"]; }; }; }; }; - get_1: { + listMySkillSuiteWorkspace_1: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query?: { + q?: string; + state?: string; + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -15046,58 +16738,65 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePromotionRevocationResponse"]; + "*/*": components["schemas"]["ApiResponseMySkillSuiteWorkspaceResponse"]; }; }; }; }; - history: { + listMySkillSuites: { parameters: { - query?: never; - header?: never; - path: { - sourceSkillId: number; + query?: { + q?: string; + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description Manageable Suite page returned */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseMySkillSuiteSummaryResponse"]; }; }; }; }; - history_1: { + listMySkillSuites_1: { parameters: { - query?: never; - header?: never; - path: { - sourceSkillId: number; + query?: { + q?: string; + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description Manageable Suite page returned */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseMySkillSuiteSummaryResponse"]; }; }; }; }; - pending: { + listMySubscriptions: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; path?: never; cookie?: never; @@ -15110,14 +16809,17 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - pending_1: { + listMySubscriptions_1: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + }; header?: never; path?: never; cookie?: never; @@ -15130,12 +16832,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListPromotionRevocationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - reviewedHistory: { + listMyStars: { parameters: { query?: { page?: number; @@ -15153,12 +16855,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponsePromotionRevocationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - reviewedHistory_1: { + listMyStars_1: { parameters: { query?: { page?: number; @@ -15176,14 +16878,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponsePromotionRevocationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - unreadCount: { + listMySkills: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + filter?: string; + q?: string; + namespace?: string; + }; header?: never; path?: never; cookie?: never; @@ -15196,14 +16904,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMapStringLong"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - unreadCount_1: { + listMySkills_1: { parameters: { - query?: never; + query?: { + page?: number; + size?: number; + filter?: string; + q?: string; + namespace?: string; + }; header?: never; path?: never; cookie?: never; @@ -15216,17 +16930,15 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMapStringLong"]; + "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; }; }; }; }; - list_2: { + listMyNamespacesPage: { parameters: { - query?: { - category?: string; - page?: number; - size?: number; + query: { + pageable: components["schemas"]["Pageable"]; }; header?: never; path?: never; @@ -15240,17 +16952,15 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseNotificationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; }; }; }; }; - list_3: { + listMyNamespacesPage_1: { parameters: { - query?: { - category?: string; - page?: number; - size?: number; + query: { + pageable: components["schemas"]["Pageable"]; }; header?: never; path?: never; @@ -15264,21 +16974,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseNotificationResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; }; }; }; }; - searchMemberCandidates: { + listMyNamespaces: { parameters: { - query: { - search: string; - size?: number; - }; + query?: never; header?: never; - path: { - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -15289,21 +16994,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListNamespaceCandidateUserResponse"]; + "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; }; }; }; }; - searchMemberCandidates_1: { + listMyNamespaces_1: { parameters: { - query: { - search: string; - size?: number; - }; + query?: never; header?: never; - path: { - slug: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -15314,19 +17014,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListNamespaceCandidateUserResponse"]; + "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; }; }; }; }; - listMySkillSuiteWorkspace: { + listVisibleLabels: { parameters: { - query?: { - q?: string; - state?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -15339,19 +17034,14 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMySkillSuiteWorkspaceResponse"]; + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; }; }; }; }; - listMySkillSuiteWorkspace_1: { + listVisibleLabels_1: { parameters: { - query?: { - q?: string; - state?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -15364,60 +17054,52 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMySkillSuiteWorkspaceResponse"]; + "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; }; }; }; }; - listMySkillSuites: { + summary: { parameters: { - query?: { - q?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Manageable Suite page returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseMySkillSuiteSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseGovernanceSummaryResponse"]; }; }; }; }; - listMySkillSuites_1: { + summary_1: { parameters: { - query?: { - q?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Manageable Suite page returned */ + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseMySkillSuiteSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseGovernanceSummaryResponse"]; }; }; }; }; - listMySubscriptions: { + notifications: { parameters: { query?: { page?: number; @@ -15435,12 +17117,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceNotificationResponse"]; }; }; }; }; - listMySubscriptions_1: { + notifications_1: { parameters: { query?: { page?: number; @@ -15458,14 +17140,15 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceNotificationResponse"]; }; }; }; }; - listMyStars: { + inbox: { parameters: { query?: { + type?: string; page?: number; size?: number; }; @@ -15481,14 +17164,15 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceInboxItemResponse"]; }; }; }; }; - listMyStars_1: { + inbox_1: { parameters: { query?: { + type?: string; page?: number; size?: number; }; @@ -15504,19 +17188,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceInboxItemResponse"]; }; }; }; }; - listMySkills: { + activity: { parameters: { query?: { page?: number; size?: number; - filter?: string; - q?: string; - namespace?: string; }; header?: never; path?: never; @@ -15530,19 +17211,16 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceActivityItemResponse"]; }; }; }; }; - listMySkills_1: { + activity_1: { parameters: { query?: { page?: number; size?: number; - filter?: string; - q?: string; - namespace?: string; }; header?: never; path?: never; @@ -15556,18 +17234,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseSkillSummaryResponse"]; + "*/*": components["schemas"]["ApiResponsePageResponseGovernanceActivityItemResponse"]; }; }; }; }; - listMyNamespacesPage: { + listValidationFindings: { parameters: { - query: { - pageable: components["schemas"]["Pageable"]; - }; + query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -15578,18 +17256,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; + "*/*": components["schemas"]["ApiResponseListValidationFindingResponse"]; }; }; }; }; - listMyNamespacesPage_1: { + listValidationFindings_1: { parameters: { - query: { - pageable: components["schemas"]["Pageable"]; - }; + query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -15600,16 +17278,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseMyNamespaceResponse"]; + "*/*": components["schemas"]["ApiResponseListValidationFindingResponse"]; }; }; }; }; - listMyNamespaces: { + streamValidationEvents: { parameters: { - query?: never; - header?: never; - path?: never; + query?: { + afterSeq?: number; + }; + header?: { + "Last-Event-ID"?: string; + }; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -15620,16 +17304,22 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; }; - listMyNamespaces_1: { + streamValidationEvents_1: { parameters: { - query?: never; - header?: never; - path?: never; + query?: { + afterSeq?: number; + }; + header?: { + "Last-Event-ID"?: string; + }; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -15640,16 +17330,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListMyNamespaceResponse"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; }; - listVisibleLabels: { + listValidationEvents: { parameters: { - query?: never; + query?: { + afterSeq?: number; + }; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -15660,16 +17354,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponseListValidationEventResponse"]; }; }; }; }; - listVisibleLabels_1: { + listValidationEvents_1: { parameters: { - query?: never; + query?: { + afterSeq?: number; + }; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -15680,16 +17378,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseListSkillLabelDto"]; + "*/*": components["schemas"]["ApiResponseListValidationEventResponse"]; }; }; }; }; - summary: { + getValidationRun: { parameters: { query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -15700,16 +17400,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseGovernanceSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - summary_1: { + getValidationRun_1: { parameters: { query?: never; header?: never; - path?: never; + path: { + runId: number; + }; cookie?: never; }; requestBody?: never; @@ -15720,19 +17422,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseGovernanceSummaryResponse"]; + "*/*": components["schemas"]["ApiResponseValidationRunResponse"]; }; }; }; }; - notifications: { + readDraftFile: { parameters: { - query?: { - page?: number; - size?: number; + query: { + path: string; }; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -15743,19 +17446,20 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceNotificationResponse"]; + "*/*": components["schemas"]["ApiResponseDraftFileContentResponse"]; }; }; }; }; - notifications_1: { + readDraftFile_1: { parameters: { - query?: { - page?: number; - size?: number; + query: { + path: string; }; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -15766,20 +17470,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceNotificationResponse"]; + "*/*": components["schemas"]["ApiResponseDraftFileContentResponse"]; }; }; }; }; - inbox: { + getAuthoringDraft: { parameters: { - query?: { - type?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -15790,20 +17492,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceInboxItemResponse"]; + "*/*": components["schemas"]["ApiResponseDraftResponse"]; }; }; }; }; - inbox_1: { + deleteAuthoringDraft: { parameters: { - query?: { - type?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -15814,19 +17514,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceInboxItemResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; }; - activity: { + getAuthoringDraft_1: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -15837,19 +17536,18 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceActivityItemResponse"]; + "*/*": components["schemas"]["ApiResponseDraftResponse"]; }; }; }; }; - activity_1: { + deleteAuthoringDraft_1: { parameters: { - query?: { - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + draftId: number; + }; cookie?: never; }; requestBody?: never; @@ -15860,7 +17558,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponsePageResponseGovernanceActivityItemResponse"]; + "*/*": components["schemas"]["ApiResponseVoid"]; }; }; }; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b6360ebfe..4ac64c67d 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -742,3 +742,123 @@ export interface NotificationPreferenceItem { export interface NotificationUnreadCount { count: number } + +// Authoring workbench types (draft create/edit/validate/submit flow) +export interface AuthoringDraft { + id: number + namespaceId: number + name: string + requirement?: string + revision: number + contentDigest: string + validated: boolean + validatedRevision?: number + validatedRunId?: number + submittedSkillId?: number + submittedVersionId?: number + submittedAt?: string + createdAt: string + updatedAt: string +} + +export interface DraftFileSummary { + id?: number + path: string + sha256: string + size: number + contentType?: string + updatedAt?: string +} + +export interface DraftFileContent { + path: string + sha256: string + size: number + contentType?: string + content: string +} + +export interface SaveDraftFileOutcome { + draft: AuthoringDraft + file: DraftFileSummary + created: boolean + revisionAdvanced: boolean +} + +export interface RuntimeBindingInfo { + // null while the draft has no saved binding yet (the form falls back to + // its local-script default) + agentType: string | null + config: Record + toolAllowlist: string[] + mcpServers: Record[] + updatedAt?: string +} + +export type ValidationRunStatus = + | 'QUEUED' + | 'PREPARING' + | 'RUNNING' + | 'SUCCEEDED' + | 'FAILED' + | 'CANCELLED' + | 'TIMED_OUT' + +export interface ValidationRunInfo { + id: number + draftId: number + draftRevision: number + status: ValidationRunStatus + cancelRequested: boolean + active: boolean + terminal: boolean + errorCount: number + warningCount: number + triggeredBy: string + startedAt?: string + finishedAt?: string + createdAt: string + summary: Record +} + +export interface ValidationEventInfo { + seq: number + type: string + phase?: string + payload: Record + createdAt?: string +} + +export interface FilePatch { + filePath: string + oldSha256?: string + oldValue?: string + newValue?: string +} + +export interface FixSuggestionInfo { + description?: string + patches?: FilePatch[] +} + +export interface ValidationFindingInfo { + id: number + runId: number + layer: 'STRUCTURE' | 'CONFIG' | 'BEHAVIOR' + ruleCode: string + severity: 'ERROR' | 'WARNING' + filePath?: string + location?: string + message: string + suggestion?: FixSuggestionInfo | null + status: 'OPEN' | 'APPLIED' | 'DISMISSED' + appliedRevision?: number + createdAt: string +} + +export interface SubmitDraftOutcome { + skillId: number + versionId: number + slug: string + version: string +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 489efd734..c77e4de50 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -176,6 +176,17 @@ const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/sta const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage', dashboardRouteOptions) const NotificationsPage = createLazyRouteComponent(() => import('@/pages/notifications'), 'NotificationsPage') const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage', dashboardRouteOptions) +const AuthoringDraftsPage = createLazyRouteComponent(() => import('@/pages/authoring/drafts'), 'DraftsPage', dashboardRouteOptions) +const AuthoringDraftDetailPage = createLazyRouteComponent( + () => import('@/pages/authoring/draft-detail'), + 'DraftDetailPage', + dashboardRouteOptions, +) +const AuthoringRunDetailPage = createLazyRouteComponent( + () => import('@/pages/authoring/run-detail'), + 'RunDetailPage', + dashboardRouteOptions, +) const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage') const SecuritySettingsPage = createLazyRouteComponent( () => import('@/pages/settings/security'), @@ -575,6 +586,27 @@ const dashboardTokensRoute = createRoute({ component: TokensPage, }) +const authoringDraftsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring', + beforeLoad: requireAuth, + component: AuthoringDraftsPage, +}) + +const authoringDraftDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring/$draftId', + beforeLoad: requireAuth, + component: AuthoringDraftDetailPage, +}) + +const authoringRunDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring/$draftId/runs/$runId', + beforeLoad: requireAuth, + component: AuthoringRunDetailPage, +}) + const cliAuthRoute = createRoute({ getParentRoute: () => rootRoute, path: 'cli/auth', @@ -691,6 +723,9 @@ const routeTree = rootRoute.addChildren([ dashboardSubscriptionsRoute, dashboardNotificationsRoute, dashboardTokensRoute, + authoringDraftsRoute, + authoringDraftDetailRoute, + authoringRunDetailRoute, cliAuthRoute, deviceAuthRoute, settingsSecurityRoute, diff --git a/web/src/features/authoring/binary-file.test.ts b/web/src/features/authoring/binary-file.test.ts new file mode 100644 index 000000000..9e076707d --- /dev/null +++ b/web/src/features/authoring/binary-file.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { formatBytes, isBinaryDraftPath } from './binary-file' + +describe('isBinaryDraftPath', () => { + it('marks image and office extensions as binary', () => { + expect(isBinaryDraftPath('assets/logo.png')).toBe(true) + expect(isBinaryDraftPath('assets/logo.PNG')).toBe(true) + expect(isBinaryDraftPath('docs/report.pdf')).toBe(true) + expect(isBinaryDraftPath('assets/sheet.xlsx')).toBe(true) + }) + + it('keeps text formats editable', () => { + expect(isBinaryDraftPath('SKILL.md')).toBe(false) + expect(isBinaryDraftPath('references/metrics.md')).toBe(false) + expect(isBinaryDraftPath('assets/diagram.svg')).toBe(false) + expect(isBinaryDraftPath('scripts/greet.sh')).toBe(false) + expect(isBinaryDraftPath('validation.yaml')).toBe(false) + }) +}) + +describe('formatBytes', () => { + it('formats byte sizes for humans', () => { + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2.0 KB') + expect(formatBytes(3 * 1024 * 1024)).toBe('3.0 MB') + }) +}) diff --git a/web/src/features/authoring/binary-file.ts b/web/src/features/authoring/binary-file.ts new file mode 100644 index 000000000..5cb9b65b0 --- /dev/null +++ b/web/src/features/authoring/binary-file.ts @@ -0,0 +1,56 @@ +/** + * Binary draft files (images, office documents, PDFs) round-trip through the + * API as base64 and cannot be edited in the plain-text editor; the workbench + * shows them read-only with an upload-to-replace action instead. + */ + +const BINARY_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', + '.pdf', '.doc', '.xls', '.ppt', '.docx', '.xlsx', '.pptx', +]) + +export function isBinaryDraftPath(path: string): boolean { + const lower = path.toLowerCase() + for (const extension of BINARY_EXTENSIONS) { + if (lower.endsWith(extension)) { + return true + } + } + return false +} + +export function formatBytes(size: number): string { + if (size < 1024) { + return `${size} B` + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB` + } + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +/** Reads a browser File as base64 (no data: prefix) for the API's base64 encoding. */ +export function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error ?? new Error('failed to read file')) + reader.onload = () => { + const buffer = reader.result + if (!(buffer instanceof ArrayBuffer)) { + reject(new Error('failed to read file')) + return + } + resolve(bytesToBase64(new Uint8Array(buffer))) + } + reader.readAsArrayBuffer(file) + }) +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} diff --git a/web/src/features/authoring/draft-file-editor.tsx b/web/src/features/authoring/draft-file-editor.tsx new file mode 100644 index 000000000..d54cd1b3f --- /dev/null +++ b/web/src/features/authoring/draft-file-editor.tsx @@ -0,0 +1,352 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FilePlus2, Save, Trash2, Upload } from 'lucide-react' +import { authoringApi } from '@/api/client' +import type { DraftFileSummary } from '@/api/types' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Textarea } from '@/shared/ui/textarea' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { toast } from '@/shared/lib/toast' +import { fileToBase64, formatBytes, isBinaryDraftPath } from './binary-file' + +/** + * Draft file editor: a file picker plus a plain-text editor for text formats + * and an upload-to-replace view for binary assets (images, documents). Saves + * go through the draft's optimistic-concurrency revision so concurrent edits + * surface as errors instead of silently overwriting each other. + */ +export function DraftFileEditor({ + draftId, + files, + revision, + onSaved, + onDeleted, +}: { + draftId: number + files: DraftFileSummary[] + revision: number + onSaved: (path: string) => void + onDeleted: (path: string) => void +}) { + const { t } = useTranslation() + const sortedFiles = useMemo( + () => [...files].sort((a, b) => a.path.localeCompare(b.path)), + [files], + ) + const [selectedPath, setSelectedPath] = useState(null) + const [content, setContent] = useState('') + const [dirty, setDirty] = useState(false) + const [saving, setSaving] = useState(false) + const [newPath, setNewPath] = useState('') + const [creating, setCreating] = useState(false) + + const [uploadOpen, setUploadOpen] = useState(false) + const [uploadFile, setUploadFile] = useState(null) + const [uploadPath, setUploadPath] = useState('') + const [uploading, setUploading] = useState(false) + + const selected = sortedFiles.find((file) => file.path === selectedPath) ?? null + const binarySelected = selectedPath != null && isBinaryDraftPath(selectedPath) + + useEffect(() => { + if (sortedFiles.length === 0) { + setSelectedPath(null) + return + } + if (selectedPath == null) { + setSelectedPath(sortedFiles[0].path) + } + }, [sortedFiles, selectedPath]) + + useEffect(() => { + if (selectedPath == null || isBinaryDraftPath(selectedPath)) { + return + } + let cancelled = false + authoringApi + .readFile(draftId, selectedPath) + .then((file) => { + if (!cancelled) { + setContent(file.content) + setDirty(false) + } + }) + .catch((error) => { + if (!cancelled) { + toast.error(error instanceof Error ? error.message : String(error)) + } + }) + return () => { + cancelled = true + } + }, [draftId, selectedPath]) + + const save = async () => { + if (selectedPath == null) { + return + } + setSaving(true) + try { + await authoringApi.saveFile(draftId, { + path: selectedPath, + content, + expectedRevision: revision, + }) + setDirty(false) + onSaved(selectedPath) + toast.success(t('authoring.files.saved')) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setSaving(false) + } + } + + const createFile = async () => { + const path = newPath.trim() + if (!path) { + return + } + setCreating(true) + try { + const outcome = await authoringApi.saveFile(draftId, { + path, + content: '', + expectedRevision: revision, + }) + setNewPath('') + onSaved(path) + setSelectedPath(path) + toast.success( + outcome.created ? t('authoring.files.created') : t('authoring.files.exists'), + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setCreating(false) + } + } + + const remove = async () => { + if (selectedPath == null) { + return + } + const removedPath = selectedPath + try { + await authoringApi.deleteFile(draftId, removedPath, revision) + // pick the next selection from the files we know remain — the list prop + // is still stale at this point, so the removed path itself must be + // excluded explicitly + const next = sortedFiles.find((file) => file.path !== removedPath)?.path ?? null + onDeleted(removedPath) + setSelectedPath(next) + toast.success(t('authoring.files.deleted')) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } + } + + const openUpload = (prefillPath?: string) => { + setUploadFile(null) + setUploadPath(prefillPath ?? '') + setUploadOpen(true) + } + + const chooseUploadFile = (file: File | null) => { + setUploadFile(file) + if (file && !uploadPath.trim()) { + setUploadPath(`assets/${file.name}`) + } + } + + const upload = async () => { + if (!uploadFile) { + return + } + const path = uploadPath.trim() + if (!path) { + return + } + setUploading(true) + try { + const base64 = await fileToBase64(uploadFile) + const outcome = await authoringApi.saveFile(draftId, { + path, + content: base64, + base64: true, + contentType: uploadFile.type || undefined, + expectedRevision: revision, + }) + setUploadOpen(false) + onSaved(path) + setSelectedPath(path) + toast.success( + outcome.created ? t('authoring.files.uploaded') : t('authoring.files.replaced'), + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setUploading(false) + } + } + + return ( +

+
+
    + {sortedFiles.map((file) => ( +
  • + +
  • + ))} +
+
+ setNewPath(event.target.value)} + placeholder="scripts/run.py" + className="h-8 font-mono text-xs" + onKeyDown={(event) => { + if (event.key === 'Enter') { + void createFile() + } + }} + /> + +
+ +
+ +
+ {selectedPath == null ? ( +

{t('authoring.files.empty')}

+ ) : binarySelected && selected ? ( +
+

{selected.path}

+
+
{t('authoring.files.size')}
+
{formatBytes(selected.size)}
+
sha256
+
+ {selected.sha256.slice(0, 16)}… +
+
{t('authoring.files.contentType')}
+
{selected.contentType || '—'}
+
+

{t('authoring.files.binaryHint')}

+
+ + +
+
+ ) : ( + <> +