diff --git a/.github/actions/basic-checks/action.yaml b/.github/actions/basic-checks/action.yaml index efe15f965ba..df509fd7ef2 100644 --- a/.github/actions/basic-checks/action.yaml +++ b/.github/actions/basic-checks/action.yaml @@ -38,6 +38,10 @@ runs: shell: bash run: npm run build:cli + - name: Validate config schemas + shell: bash + run: npm run validate:configs + - name: Run checks shell: bash run: npx prek run --all-files --stage pre-push diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f98a20761a2..4e33e585fb0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -163,6 +163,16 @@ repos: - id: check-shebang-scripts-are-executable priority: 10 + - repo: local + hooks: + - id: validate-config-schemas + name: Validate config files against JSON schemas + entry: npx tsx scripts/validate-configs.ts + language: system + pass_filenames: false + files: ^(nemoclaw-blueprint/.*\.yaml$|nemoclaw/openclaw\.plugin\.json$|schemas/.*\.json$) + priority: 10 + - repo: https://github.com/shellcheck-py/shellcheck-py rev: v0.11.0.1 hooks: diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index 0e495aa360a..4fd6eff013c 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -64,4 +64,4 @@ components: endpoints: - host: "nim-service.local" port: 8000 - protocol: rest + access: full diff --git a/package.json b/package.json index 229bb421627..30524672bd9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "typecheck": "tsc -p jsconfig.json", "build:cli": "tsc -p tsconfig.src.json", "typecheck:cli": "tsc -p tsconfig.cli.json", + "validate:configs": "tsx scripts/validate-configs.ts", "migrate:js-to-ts": "tsx scripts/migrate-js-to-ts.ts", "ts-migration:assist": "tsx scripts/ts-migration-assist.ts", "ts-migration:bulk-fix-prs": "tsx scripts/ts-migration-bulk-fix-prs.ts", @@ -54,6 +55,7 @@ "@j178/prek": "^0.3.6", "@types/node": "^25.5.0", "@typescript-eslint/parser": "^8.58.1", + "ajv": "^8.17.0", "@vitest/coverage-v8": "^4.1.0", "eslint": "^10.1.0", "execa": "^9.6.1", diff --git a/schemas/blueprint.schema.json b/schemas/blueprint.schema.json new file mode 100644 index 00000000000..3b3f14104ea --- /dev/null +++ b/schemas/blueprint.schema.json @@ -0,0 +1,172 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/NVIDIA/NemoClaw/schemas/blueprint.schema.json", + "title": "NemoClaw Blueprint", + "description": "Schema for nemoclaw-blueprint/blueprint.yaml — defines sandbox orchestration, inference profiles, and policy configuration.", + "type": "object", + "required": ["version", "profiles", "components"], + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "description": "Semver version of the blueprint." + }, + "min_openshell_version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "description": "Minimum compatible OpenShell version." + }, + "min_openclaw_version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "description": "Minimum compatible OpenClaw version." + }, + "digest": { + "type": "string", + "description": "Computed at release time." + }, + "profiles": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "uniqueItems": true, + "description": "Declared inference profile names." + }, + "description": { + "type": "string" + }, + "components": { + "type": "object", + "required": ["sandbox", "inference"], + "additionalProperties": false, + "properties": { + "sandbox": { + "type": "object", + "required": ["image", "name"], + "additionalProperties": false, + "properties": { + "image": { + "type": "string", + "description": "Container image for the sandbox." + }, + "name": { + "type": "string", + "description": "Default sandbox name." + }, + "forward_ports": { + "type": "array", + "items": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + } + }, + "inference": { + "type": "object", + "required": ["profiles"], + "additionalProperties": false, + "properties": { + "profiles": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/inferenceProfile" + } + } + } + }, + "policy": { + "type": "object", + "required": ["base"], + "additionalProperties": false, + "properties": { + "base": { + "type": "string", + "description": "Path to the base sandbox policy file." + }, + "additions": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/policyAddition" + } + } + } + } + } + } + }, + "$defs": { + "inferenceProfile": { + "type": "object", + "required": ["provider_type", "endpoint"], + "additionalProperties": false, + "properties": { + "provider_type": { "type": "string" }, + "provider_name": { "type": "string" }, + "endpoint": { "type": "string" }, + "model": { "type": "string" }, + "credential_env": { "type": "string" }, + "credential_default": { "type": "string" }, + "timeout_secs": { "type": "integer", "minimum": 1 }, + "dynamic_endpoint": { "type": "boolean" } + } + }, + "policyAddition": { + "type": "object", + "required": ["name", "endpoints"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "endpoints": { + "type": "array", + "items": { "$ref": "#/$defs/endpoint" }, + "minItems": 1 + } + } + }, + "endpoint": { + "type": "object", + "required": ["host", "port"], + "additionalProperties": false, + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "protocol": { "type": "string", "enum": ["rest"] }, + "enforcement": { "type": "string", "enum": ["enforce", "audit"] }, + "tls": { "type": "string", "enum": ["terminate", "passthrough"] }, + "access": { "type": "string", "enum": ["full"] }, + "rules": { + "type": "array", + "items": { "$ref": "#/$defs/rule" }, + "minItems": 1 + } + }, + "if": { + "properties": { "protocol": { "const": "rest" } }, + "required": ["protocol"] + }, + "then": { "required": ["rules"] } + }, + "rule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { + "type": "object", + "required": ["method", "path"], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] + }, + "path": { + "type": "string", + "pattern": "^/" + } + } + } + } + } + } +} diff --git a/schemas/onboard-config.schema.json b/schemas/onboard-config.schema.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/schemas/openclaw-plugin.schema.json b/schemas/openclaw-plugin.schema.json new file mode 100644 index 00000000000..fd651e88fa6 --- /dev/null +++ b/schemas/openclaw-plugin.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/NVIDIA/NemoClaw/schemas/openclaw-plugin.schema.json", + "title": "NemoClaw OpenClaw Plugin Manifest", + "description": "Schema for nemoclaw/openclaw.plugin.json — the OpenClaw plugin manifest that declares the plugin identity and its configuration schema.", + "type": "object", + "required": ["id", "name", "version", "description", "configSchema"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Unique plugin identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Human-readable plugin name." + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "description": "Semver version of the plugin." + }, + "description": { + "type": "string", + "minLength": 1, + "description": "Short description of the plugin." + }, + "configSchema": { + "type": "object", + "description": "JSON Schema object describing the plugin's configuration fields." + } + } +} diff --git a/schemas/policy-preset.schema.json b/schemas/policy-preset.schema.json new file mode 100644 index 00000000000..44581baabac --- /dev/null +++ b/schemas/policy-preset.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/NVIDIA/NemoClaw/schemas/policy-preset.schema.json", + "title": "NemoClaw Policy Preset", + "description": "Schema for policy presets (nemoclaw-blueprint/policies/presets/*.yaml) — named network policy bundles that can be merged into the base sandbox policy.", + "type": "object", + "required": ["preset", "network_policies"], + "additionalProperties": false, + "properties": { + "preset": { + "type": "object", + "required": ["name", "description"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 } + } + }, + "network_policies": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/networkPolicyEntry" + } + } + }, + "$defs": { + "networkPolicyEntry": { + "type": "object", + "required": ["name", "endpoints"], + "properties": { + "name": { "type": "string" }, + "endpoints": { + "type": "array", + "items": { "$ref": "#/$defs/endpoint" }, + "minItems": 1 + }, + "binaries": { + "type": "array", + "items": { "$ref": "#/$defs/binary" } + } + } + }, + "endpoint": { + "type": "object", + "required": ["host", "port"], + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "protocol": { "type": "string", "enum": ["rest"] }, + "enforcement": { "type": "string", "enum": ["enforce", "audit"] }, + "tls": { "type": "string", "enum": ["terminate", "passthrough"] }, + "access": { "type": "string", "enum": ["full"] }, + "rules": { + "type": "array", + "items": { "$ref": "#/$defs/rule" }, + "minItems": 1 + } + }, + "if": { + "properties": { "protocol": { "const": "rest" } }, + "required": ["protocol"] + }, + "then": { "required": ["rules"] } + }, + "rule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { + "type": "object", + "required": ["method", "path"], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] + }, + "path": { + "type": "string", + "pattern": "^/" + } + } + } + } + }, + "binary": { + "type": "object", + "required": ["path"], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "pattern": "^/" + } + } + } + } +} diff --git a/schemas/sandbox-policy.schema.json b/schemas/sandbox-policy.schema.json new file mode 100644 index 00000000000..77c3714c3d4 --- /dev/null +++ b/schemas/sandbox-policy.schema.json @@ -0,0 +1,125 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/NVIDIA/NemoClaw/schemas/sandbox-policy.schema.json", + "title": "NemoClaw Sandbox Policy", + "description": "Schema for the base sandbox policy (openclaw-sandbox.yaml) — defines filesystem, process, and network egress rules.", + "type": "object", + "required": ["version", "network_policies"], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "minimum": 1 + }, + "filesystem_policy": { + "type": "object", + "properties": { + "include_workdir": { "type": "boolean" }, + "read_only": { + "type": "array", + "items": { "type": "string" } + }, + "read_write": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "landlock": { + "type": "object", + "properties": { + "compatibility": { + "type": "string", + "enum": ["strict", "best_effort"] + } + } + }, + "process": { + "type": "object", + "properties": { + "run_as_user": { "type": "string" }, + "run_as_group": { "type": "string" } + } + }, + "network_policies": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/networkPolicyEntry" + } + } + }, + "$defs": { + "networkPolicyEntry": { + "type": "object", + "required": ["name", "endpoints"], + "properties": { + "name": { "type": "string" }, + "endpoints": { + "type": "array", + "items": { "$ref": "#/$defs/endpoint" }, + "minItems": 1 + }, + "binaries": { + "type": "array", + "items": { "$ref": "#/$defs/binary" } + } + } + }, + "endpoint": { + "type": "object", + "required": ["host", "port"], + "properties": { + "host": { "type": "string" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "protocol": { "type": "string", "enum": ["rest"] }, + "enforcement": { "type": "string", "enum": ["enforce", "audit"] }, + "tls": { "type": "string", "enum": ["terminate", "passthrough"] }, + "access": { "type": "string", "enum": ["full"] }, + "rules": { + "type": "array", + "items": { "$ref": "#/$defs/rule" }, + "minItems": 1 + } + }, + "if": { + "properties": { "protocol": { "const": "rest" } }, + "required": ["protocol"] + }, + "then": { "required": ["rules"] } + }, + "rule": { + "type": "object", + "required": ["allow"], + "additionalProperties": false, + "properties": { + "allow": { + "type": "object", + "required": ["method", "path"], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] + }, + "path": { + "type": "string", + "pattern": "^/" + } + } + } + } + }, + "binary": { + "type": "object", + "required": ["path"], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "pattern": "^/" + } + } + } + } +} diff --git a/scripts/validate-configs.ts b/scripts/validate-configs.ts new file mode 100755 index 00000000000..787074903cd --- /dev/null +++ b/scripts/validate-configs.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env -S npx tsx +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Validates NemoClaw configuration files against JSON Schemas. +// Used by CI (basic-checks) and locally via `npm run validate:configs`. +// +// Usage: +// npx tsx scripts/validate-configs.ts # validate all known config files +// npx tsx scripts/validate-configs.ts --file --schema # validate one file + +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv from "ajv/dist/2020.js"; +import YAML from "yaml"; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); + +interface ConfigTarget { + schema: string; + files: string[]; +} + +/** All config files validated by default (paths relative to repo root). */ +function discoverTargets(): ConfigTarget[] { + const targets: ConfigTarget[] = [ + { + schema: "schemas/blueprint.schema.json", + files: ["nemoclaw-blueprint/blueprint.yaml"], + }, + { + schema: "schemas/sandbox-policy.schema.json", + files: ["nemoclaw-blueprint/policies/openclaw-sandbox.yaml"], + }, + { + schema: "schemas/openclaw-plugin.schema.json", + files: ["nemoclaw/openclaw.plugin.json"], + }, + ]; + + // Discover all preset YAML files dynamically. + const presetsDir = join(REPO_ROOT, "nemoclaw-blueprint/policies/presets"); + try { + const presetFiles = readdirSync(presetsDir) + .filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")) + .map((f) => `nemoclaw-blueprint/policies/presets/${f}`); + if (presetFiles.length > 0) { + targets.push({ + schema: "schemas/policy-preset.schema.json", + files: presetFiles, + }); + } else { + console.warn("WARN: presets directory exists but contains no .yaml/.yml files — no preset validation performed"); + } + } catch (err) { + const code = (err as { code?: string }).code; + if (code !== "ENOENT" && code !== "ENOTDIR") throw err; + // presets directory may not exist — not an error + } + + return targets; +} + +function loadFile(repoRelative: string): unknown { + const abs = join(REPO_ROOT, repoRelative); + const raw = readFileSync(abs, "utf-8"); + if (repoRelative.endsWith(".yaml") || repoRelative.endsWith(".yml")) { + return YAML.parse(raw); + } + return JSON.parse(raw); +} + +function loadSchema(repoRelative: string): object { + const abs = join(REPO_ROOT, repoRelative); + return JSON.parse(readFileSync(abs, "utf-8")) as object; +} + +function formatError(err: { instancePath: string; keyword?: string; message?: string; params?: Record }): string { + const path = err.instancePath || "/"; + const detail = err.params?.additionalProperty + ? `${err.message} '${err.params.additionalProperty}'` + : err.params?.unevaluatedProperty + ? `${err.message} '${err.params.unevaluatedProperty}'` + : err.message ?? "unknown error"; + return ` ${path}: ${detail}`; +} + +function main(): void { + const args = process.argv.slice(2); + + let targets: ConfigTarget[]; + + const hasFileFlag = args.indexOf("--file") !== -1; + const hasSchemaFlag = args.indexOf("--schema") !== -1; + if (hasFileFlag !== hasSchemaFlag) { + console.error("Usage: validate-configs.ts --file --schema "); + process.exitCode = 1; + return; + } + if (hasFileFlag && hasSchemaFlag) { + const fileIdx = args.indexOf("--file"); + const schemaIdx = args.indexOf("--schema"); + const file = args[fileIdx + 1]; + const schema = args[schemaIdx + 1]; + if (!file || !schema || file.startsWith("-") || schema.startsWith("-")) { + console.error("Usage: validate-configs.ts --file --schema "); + process.exitCode = 1; + return; + } + targets = [{ schema, files: [file] }]; + } else { + targets = discoverTargets(); + } + + const ajv = new Ajv({ allErrors: true, strict: false }); + let totalErrors = 0; + let totalFiles = 0; + + console.log("=== Config Schema Validation ===\n"); + + for (const target of targets) { + let validate; + try { + const schema = loadSchema(target.schema); + validate = ajv.compile(schema); + } catch (err) { + console.error(`FAIL: ${target.schema}`); + console.error(` Could not compile schema: ${err}`); + totalErrors++; + continue; + } + + for (const file of target.files) { + totalFiles++; + let data: unknown; + try { + data = loadFile(file); + } catch (err) { + console.error(`FAIL: ${file}`); + console.error(` Could not load file: ${err}`); + totalErrors++; + continue; + } + + const valid = validate(data); + if (!valid && validate.errors) { + console.error(`FAIL: ${file}`); + for (const err of validate.errors) { + console.error(formatError(err)); + } + totalErrors += validate.errors.length; + } else { + console.log(`OK: ${file}`); + } + } + } + + console.log(); + if (totalErrors > 0) { + console.error(`${totalErrors} validation error(s) across ${totalFiles} file(s).`); + process.exitCode = 1; + } else { + console.log(`All ${totalFiles} config file(s) pass schema validation.`); + } +} + +main(); diff --git a/test/validate-config-schemas.test.ts b/test/validate-config-schemas.test.ts new file mode 100644 index 00000000000..1503bfec04f --- /dev/null +++ b/test/validate-config-schemas.test.ts @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Validate config files against their JSON Schemas. + * + * Complements validate-blueprint.test.ts (business-logic invariants) with + * structural/type validation via JSON Schema. Runs as part of the "cli" + * Vitest project. + */ + +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; +import Ajv, { type ValidateFunction } from "ajv/dist/2020.js"; +import YAML from "yaml"; + +const REPO_ROOT = new URL("..", import.meta.url); + +function repoPath(...segments: string[]): string { + return new URL(join(...segments), REPO_ROOT).pathname; +} + +function loadYAML(path: string): unknown { + return YAML.parse(readFileSync(path, "utf-8")); +} + +function loadJSON(path: string): unknown { + return JSON.parse(readFileSync(path, "utf-8")); +} + +function compileSchema(schemaRelPath: string): ValidateFunction { + const ajv = new Ajv({ allErrors: true, strict: false }); + const schema = loadJSON(repoPath(schemaRelPath)); + return ajv.compile(schema as object); +} + +function expectValid(validate: ValidateFunction, data: unknown, label: string): void { + const valid = validate(data); + if (!valid) { + const messages = (validate.errors ?? []).map( + (e) => ` ${e.instancePath || "/"}: ${e.message}`, + ); + expect.unreachable(`${label} failed schema validation:\n${messages.join("\n")}`); + } +} + +// ── Blueprint ──────────────────────────────────────────────────────────────── + +describe("blueprint.schema.json", () => { + const validate = compileSchema("schemas/blueprint.schema.json"); + const data = loadYAML(repoPath("nemoclaw-blueprint/blueprint.yaml")); + + it("blueprint.yaml passes schema validation", () => { + expectValid(validate, data, "blueprint.yaml"); + }); + + it("rejects blueprint with missing required field", () => { + const bad = { ...(data as object) }; + delete (bad as Record).version; + expect(validate(bad)).toBe(false); + }); + + it("rejects blueprint with wrong type for version", () => { + const bad = { ...(data as object), version: 123 }; + expect(validate(bad)).toBe(false); + }); + + it("rejects blueprint with unknown top-level property", () => { + const bad = { ...(data as object), unknownField: true }; + expect(validate(bad)).toBe(false); + }); + + it("rejects blueprint with unknown nested component property", () => { + const bad = { + ...(data as object), + components: { + ...((data as Record).components), + inference: { + ...((data as Record).components.inference), + extraField: true, + }, + }, + }; + expect(validate(bad)).toBe(false); + }); + + it("rejects blueprint inference profile with unknown property", () => { + const bad = { + ...(data as object), + components: { + ...((data as Record).components), + inference: { + ...((data as Record).components.inference), + profiles: { + ...((data as Record).components.inference.profiles), + default: { + ...((data as Record).components.inference.profiles.default), + typoField: true, + }, + }, + }, + }, + }; + expect(validate(bad)).toBe(false); + }); + + it("rejects blueprint policyAddition endpoint with protocol rest but no rules", () => { + const bad = { + version: "1.0.0", + profiles: ["default"], + components: { + sandbox: { image: "img:latest", name: "test-sandbox" }, + inference: { + profiles: { + default: { provider_type: "openai", endpoint: "https://api.openai.com" }, + }, + }, + policy: { + base: "policies/openclaw-sandbox.yaml", + additions: { + my_service: { + name: "My Service", + endpoints: [{ host: "api.example.com", port: 443, protocol: "rest" }], + }, + }, + }, + }, + }; + expect(validate(bad)).toBe(false); + }); +}); + +// ── Base sandbox policy ────────────────────────────────────────────────────── + +describe("sandbox-policy.schema.json", () => { + const validate = compileSchema("schemas/sandbox-policy.schema.json"); + const data = loadYAML( + repoPath("nemoclaw-blueprint/policies/openclaw-sandbox.yaml"), + ); + + it("openclaw-sandbox.yaml passes schema validation", () => { + expectValid(validate, data, "openclaw-sandbox.yaml"); + }); + + it("rejects policy with missing network_policies", () => { + const bad = { ...(data as object) }; + delete (bad as Record).network_policies; + expect(validate(bad)).toBe(false); + }); + + it("rejects policy with unknown top-level property", () => { + const bad = { ...(data as object), extra: true }; + expect(validate(bad)).toBe(false); + }); + + it("rejects sandbox-policy endpoint with protocol rest but no rules", () => { + const bad = { + version: 1, + network_policies: { + test_service: { + name: "Test Service", + endpoints: [{ host: "api.example.com", port: 443, protocol: "rest" }], + }, + }, + }; + expect(validate(bad)).toBe(false); + }); +}); + +// ── Policy presets ─────────────────────────────────────────────────────────── + +describe("policy-preset.schema.json", () => { + const validate = compileSchema("schemas/policy-preset.schema.json"); + const presetsDir = repoPath("nemoclaw-blueprint/policies/presets"); + + let presetFiles: string[] = []; + try { + presetFiles = readdirSync(presetsDir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")); + } catch (err) { + if ((err as { code?: string }).code !== "ENOENT") throw err; + // directory may not exist + } + + for (const file of presetFiles) { + it(`${file} passes schema validation`, () => { + const data = loadYAML(join(presetsDir, file)); + expectValid(validate, data, file); + }); + } + + it("rejects preset without preset metadata", () => { + const bad = { network_policies: { test: { name: "test", endpoints: [{ host: "a.com", port: 443, access: "full" }] } } }; + expect(validate(bad)).toBe(false); + }); + + it("rejects preset without network_policies", () => { + const bad = { preset: { name: "test", description: "test" } }; + expect(validate(bad)).toBe(false); + }); + + it("rejects preset endpoint with protocol rest but no rules", () => { + const bad = { + preset: { name: "test", description: "test" }, + network_policies: { + test_service: { + name: "Test Service", + endpoints: [{ host: "api.example.com", port: 443, protocol: "rest" }], + }, + }, + }; + expect(validate(bad)).toBe(false); + }); +}); + +// ── OpenClaw plugin manifest ───────────────────────────────────────────────── + +describe("openclaw-plugin.schema.json", () => { + const validate = compileSchema("schemas/openclaw-plugin.schema.json"); + const data = loadJSON(repoPath("nemoclaw/openclaw.plugin.json")); + + it("openclaw.plugin.json passes schema validation", () => { + expectValid(validate, data, "openclaw.plugin.json"); + }); + + it("rejects plugin with missing id", () => { + const bad = { ...(data as object) }; + delete (bad as Record).id; + expect(validate(bad)).toBe(false); + }); + + it("rejects plugin with invalid version format", () => { + const bad = { ...(data as object), version: "not-semver" }; + expect(validate(bad)).toBe(false); + }); +});