clawsec-suite: add dynamic remote skill catalog discovery with fallback

This commit is contained in:
davida-ps
2026-02-16 08:52:58 +00:00
committed by David Abutbul
parent 21d37e59de
commit 154b89a0d0
5 changed files with 614 additions and 61 deletions
+10
View File
@@ -5,6 +5,16 @@ All notable changes to the ClawSec Suite will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Added `scripts/discover_skill_catalog.mjs` to dynamically discover installable skills from `https://clawsec.prompt.security/skills/index.json`.
- Added `test/skill_catalog_discovery.test.mjs` to validate remote-catalog loading and fallback behavior.
### Changed
- Updated `SKILL.md` to use dynamic catalog discovery commands instead of hard-coded optional-skill names.
- Documented explicit fallback behavior to suite-local `skill.json` catalog metadata when remote index fetch fails.
## [0.0.11] - 2026-02-15
### Added
+55 -61
View File
@@ -27,12 +27,23 @@ This means `clawsec-suite` can:
- OpenClaw advisory guardian hook package: `hooks/clawsec-advisory-guardian/`
- Setup scripts for hook and optional cron scheduling: `scripts/`
- Guarded installer: `scripts/guarded_skill_install.mjs`
- Dynamic catalog discovery for installable skills: `scripts/discover_skill_catalog.mjs`
- Integrated OpenClaw audit watchdog scripts: `scripts/audit-watchdog/`
- Watchdog cron bootstrap with email discovery/prompt guard: `scripts/setup_audit_watchdog.mjs`
### installed separately
- `soul-guardian`
- `clawtributor` (explicit opt-in)
### Installed separately (dynamic catalog)
`clawsec-suite` no longer hard-codes add-on skill names in this document.
Discover the current catalog from the authoritative index (`https://clawsec.prompt.security/skills/index.json`) at runtime:
```bash
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
node "$SUITE_DIR/scripts/discover_skill_catalog.mjs"
```
Fallback behavior:
- If the remote catalog index is reachable and valid, the suite uses it.
- If the remote index is unavailable or malformed, the script falls back to suite-local catalog metadata in `skill.json`.
## Installation
@@ -53,16 +64,14 @@ DEST="$INSTALL_ROOT/clawsec-suite"
BASE="https://github.com/prompt-security/clawsec/releases/download/clawsec-suite-v${VERSION}"
TEMP_DIR="$(mktemp -d)"
DOWNLOAD_DIR="$TEMP_DIR/downloads"
trap 'rm -rf "$TEMP_DIR"' EXIT
mkdir -p "$DOWNLOAD_DIR"
# Pinned release-signing public key (verify fingerprint out-of-band on first use)
# Fingerprint (SHA-256 of SPKI DER): 35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854
RELEASE_PUBKEY_SHA256="35866e1b1479a043ae816899562ac877e879320c3c5660be1e79f06241ca0854"
# Fingerprint (SHA-256 of SPKI DER): 711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8
RELEASE_PUBKEY_SHA256="711424e4535f84093fefb024cd1ca4ec87439e53907b305b79a631d5befba9c8"
cat > "$TEMP_DIR/release-signing-public.pem" <<'PEM'
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAtaRGONGp0Syl9EBS17hEYgGTwUtfZgigklS6vAe5MlQ=
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
-----END PUBLIC KEY-----
PEM
@@ -72,70 +81,48 @@ if [ "$ACTUAL_KEY_SHA256" != "$RELEASE_PUBKEY_SHA256" ]; then
exit 1
fi
# 1) Download checksums manifest + detached signature
curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.json.sig" -o "$TEMP_DIR/checksums.json.sig"
ZIP_NAME="clawsec-suite-v${VERSION}.zip"
# 2) Verify checksums manifest signature before trusting any file URLs or hashes
openssl base64 -d -A -in "$TEMP_DIR/checksums.json.sig" -out "$TEMP_DIR/checksums.json.sig.bin"
# 1) Download release archive + signed checksums manifest + signing public key
curl -fsSL "$BASE/$ZIP_NAME" -o "$TEMP_DIR/$ZIP_NAME"
curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json"
curl -fsSL "$BASE/checksums.sig" -o "$TEMP_DIR/checksums.sig"
# 2) Verify checksums manifest signature before trusting any hashes
openssl base64 -d -A -in "$TEMP_DIR/checksums.sig" -out "$TEMP_DIR/checksums.sig.bin"
if ! openssl pkeyutl -verify \
-pubin \
-inkey "$TEMP_DIR/release-signing-public.pem" \
-sigfile "$TEMP_DIR/checksums.json.sig.bin" \
-sigfile "$TEMP_DIR/checksums.sig.bin" \
-rawin \
-in "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
echo "ERROR: checksums.json signature verification failed" >&2
exit 1
fi
if ! jq -e '.skill and .version and .files' "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then
echo "ERROR: Invalid checksums.json format" >&2
EXPECTED_ZIP_SHA="$(jq -r '.archive.sha256 // empty' "$TEMP_DIR/checksums.json")"
if [ -z "$EXPECTED_ZIP_SHA" ]; then
echo "ERROR: checksums.json missing archive.sha256" >&2
exit 1
fi
echo "Checksums manifest signature verified."
if command -v shasum >/dev/null 2>&1; then
ACTUAL_ZIP_SHA="$(shasum -a 256 "$TEMP_DIR/$ZIP_NAME" | awk '{print $1}')"
else
ACTUAL_ZIP_SHA="$(sha256sum "$TEMP_DIR/$ZIP_NAME" | awk '{print $1}')"
fi
# 3) Download every file listed in checksums and verify immediately
DOWNLOAD_FAILED=0
for file in $(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json"); do
FILE_URL="$(jq -r --arg f "$file" '.files[$f].url' "$TEMP_DIR/checksums.json")"
EXPECTED="$(jq -r --arg f "$file" '.files[$f].sha256' "$TEMP_DIR/checksums.json")"
if ! curl -fsSL "$FILE_URL" -o "$DOWNLOAD_DIR/$file"; then
echo "ERROR: Download failed for $file" >&2
DOWNLOAD_FAILED=1
continue
fi
if command -v shasum >/dev/null 2>&1; then
ACTUAL="$(shasum -a 256 "$DOWNLOAD_DIR/$file" | awk '{print $1}')"
else
ACTUAL="$(sha256sum "$DOWNLOAD_DIR/$file" | awk '{print $1}')"
fi
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "ERROR: Checksum mismatch for $file" >&2
DOWNLOAD_FAILED=1
else
echo "Verified: $file"
fi
done
if [ "$DOWNLOAD_FAILED" -eq 1 ]; then
echo "ERROR: One or more files failed verification" >&2
if [ "$EXPECTED_ZIP_SHA" != "$ACTUAL_ZIP_SHA" ]; then
echo "ERROR: Archive checksum mismatch for $ZIP_NAME" >&2
exit 1
fi
# 4) Install files using paths from checksums.json
while IFS= read -r file; do
[ -z "$file" ] && continue
REL_PATH="$(jq -r --arg f "$file" '.files[$f].path // $f' "$TEMP_DIR/checksums.json")"
SRC_PATH="$DOWNLOAD_DIR/$file"
DST_PATH="$DEST/$REL_PATH"
echo "Checksums manifest signature and archive hash verified."
mkdir -p "$(dirname "$DST_PATH")"
cp "$SRC_PATH" "$DST_PATH"
done < <(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json")
# 3) Install verified archive
mkdir -p "$INSTALL_ROOT"
rm -rf "$DEST"
unzip -q "$TEMP_DIR/$ZIP_NAME" -d "$INSTALL_ROOT"
chmod 600 "$DEST/skill.json"
find "$DEST" -type f ! -name "skill.json" -exec chmod 644 {} \;
@@ -207,7 +194,7 @@ This enforces:
The embedded feed logic uses these defaults:
- Remote feed URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json`
- Remote feed URL: `https://clawsec.prompt.security/advisories/feed.json`
- Remote feed signature URL: `${CLAWSEC_FEED_URL}.sig` (override with `CLAWSEC_FEED_SIG_URL`)
- Remote checksums manifest URL: sibling `checksums.json` (override with `CLAWSEC_FEED_CHECKSUMS_URL`)
- Local seed fallback: `~/.openclaw/skills/clawsec-suite/advisories/feed.json`
@@ -222,7 +209,7 @@ The embedded feed logic uses these defaults:
### Quick feed check
```bash
FEED_URL="${CLAWSEC_FEED_URL:-https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json}"
FEED_URL="${CLAWSEC_FEED_URL:-https://clawsec.prompt.security/advisories/feed.json}"
STATE_FILE="${CLAWSEC_SUITE_STATE_FILE:-$HOME/.openclaw/clawsec-suite-feed-state.json}"
TMP="$(mktemp -d)"
@@ -286,13 +273,20 @@ The suite hook and heartbeat guidance are intentionally non-destructive by defau
## Optional Skill Installation
Install additional protections as needed:
Discover currently available installable skills dynamically, then install the ones you want:
```bash
# audit watchdog is integrated in clawsec-suite
npx clawhub@latest install soul-guardian
# opt-in only:
npx clawhub@latest install clawtributor
SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite"
node "$SUITE_DIR/scripts/discover_skill_catalog.mjs"
# then install any discovered skill by name
npx clawhub@latest install <skill-name>
```
Machine-readable output is also available for automation:
```bash
node "$SUITE_DIR/scripts/discover_skill_catalog.mjs" --json
```
## Security Notes
@@ -0,0 +1,294 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_INDEX_URL = "https://clawsec.prompt.security/skills/index.json";
const DEFAULT_TIMEOUT_MS = 5000;
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const SUITE_DIR = path.resolve(SCRIPT_DIR, "..");
const SUITE_SKILL_JSON = path.join(SUITE_DIR, "skill.json");
function isObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeSkillId(value) {
return String(value ?? "")
.trim()
.toLowerCase();
}
function normalizeBoolean(value) {
return value === true;
}
function parseTimeoutMs() {
const raw = String(process.env.CLAWSEC_SKILLS_INDEX_TIMEOUT_MS ?? "").trim();
if (!raw) return DEFAULT_TIMEOUT_MS;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_TIMEOUT_MS;
}
return parsed;
}
function parseArgs(argv) {
const args = {
json: false,
};
for (const token of argv) {
if (token === "--json") {
args.json = true;
continue;
}
if (token === "--help" || token === "-h") {
printUsage();
process.exit(0);
}
throw new Error(`Unknown argument: ${token}`);
}
return args;
}
function printUsage() {
process.stdout.write(
[
"Usage:",
" node scripts/discover_skill_catalog.mjs [--json]",
"",
"Behavior:",
" - Fetches dynamic catalog from CLAWSEC_SKILLS_INDEX_URL (default: https://clawsec.prompt.security/skills/index.json)",
" - Falls back to suite-local catalog metadata in skill.json when remote index is unavailable/invalid",
"",
"Environment:",
" CLAWSEC_SKILLS_INDEX_URL Override remote catalog index URL",
" CLAWSEC_SKILLS_INDEX_TIMEOUT_MS HTTP timeout in milliseconds (default: 5000)",
"",
].join("\n"),
);
}
function normalizeRemoteSkills(payload) {
if (!isObject(payload)) {
throw new Error("Catalog index payload must be a JSON object");
}
const rawSkills = payload.skills;
if (!Array.isArray(rawSkills)) {
throw new Error("Catalog index missing skills array");
}
const dedup = new Map();
for (const entry of rawSkills) {
if (!isObject(entry)) continue;
const id = normalizeSkillId(entry.id ?? entry.name);
if (!id) continue;
dedup.set(id, {
id,
name: String(entry.name ?? id),
version: String(entry.version ?? "").trim() || null,
description: String(entry.description ?? "").trim() || null,
emoji: String(entry.emoji ?? "").trim() || null,
category: String(entry.category ?? "").trim() || null,
tag: String(entry.tag ?? "").trim() || null,
trust: entry.trust ?? null,
source: "remote",
});
}
return {
version: String(payload.version ?? "").trim() || null,
updated: String(payload.updated ?? "").trim() || null,
skills: [...dedup.values()].sort((a, b) => a.id.localeCompare(b.id)),
};
}
async function loadFallbackCatalog() {
const raw = await fs.readFile(SUITE_SKILL_JSON, "utf8");
const parsed = JSON.parse(raw);
const catalogSkills = isObject(parsed?.catalog?.skills) ? parsed.catalog.skills : {};
const dedup = new Map();
for (const [rawId, meta] of Object.entries(catalogSkills)) {
const id = normalizeSkillId(rawId);
if (!id) continue;
const safeMeta = isObject(meta) ? meta : {};
dedup.set(id, {
id,
name: id,
version: null,
description: String(safeMeta.description ?? "").trim() || null,
emoji: null,
category: null,
tag: null,
trust: null,
source: "fallback",
integrated_in_suite: normalizeBoolean(safeMeta.integrated_in_suite),
requires_explicit_consent: normalizeBoolean(safeMeta.requires_explicit_consent),
default_install: normalizeBoolean(safeMeta.default_install),
});
}
return {
version: null,
updated: null,
skills: [...dedup.values()].sort((a, b) => a.id.localeCompare(b.id)),
};
}
function mergeWithFallbackMetadata(remoteSkills, fallbackSkills) {
const fallbackById = new Map(fallbackSkills.map((skill) => [skill.id, skill]));
return remoteSkills.map((skill) => {
const fallback = fallbackById.get(skill.id);
if (!fallback) {
return {
...skill,
integrated_in_suite: false,
requires_explicit_consent: false,
default_install: false,
};
}
return {
...skill,
description: skill.description || fallback.description || null,
integrated_in_suite: normalizeBoolean(fallback.integrated_in_suite),
requires_explicit_consent: normalizeBoolean(fallback.requires_explicit_consent),
default_install: normalizeBoolean(fallback.default_install),
};
});
}
async function loadRemoteCatalog(indexUrl, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(indexUrl, {
method: "GET",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} while fetching catalog`);
}
const payload = await response.json();
return normalizeRemoteSkills(payload);
} finally {
clearTimeout(timeout);
}
}
function formatFlags(skill) {
const flags = [];
if (skill.id === "clawsec-suite") {
flags.push("this suite");
}
if (skill.integrated_in_suite) {
flags.push("already integrated in suite");
}
if (skill.requires_explicit_consent) {
flags.push("explicit opt-in");
}
if (skill.default_install) {
flags.push("recommended default");
}
return flags;
}
function printHumanSummary(result) {
process.stdout.write("=== ClawSec Skill Catalog Discovery ===\n");
process.stdout.write(`Source: ${result.source}\n`);
process.stdout.write(`Index URL: ${result.index_url}\n`);
if (result.updated) {
process.stdout.write(`Catalog updated: ${result.updated}\n`);
}
if (result.warning) {
process.stdout.write(`Fallback reason: ${result.warning}\n`);
}
process.stdout.write("\nAvailable installable skills:\n");
if (!Array.isArray(result.skills) || result.skills.length === 0) {
process.stdout.write("- none\n");
return;
}
for (const skill of result.skills) {
const label = skill.version ? `${skill.id} (v${skill.version})` : skill.id;
process.stdout.write(`- ${label}\n`);
if (skill.description) {
process.stdout.write(` ${skill.description}\n`);
}
const flags = formatFlags(skill);
if (flags.length > 0) {
process.stdout.write(` notes: ${flags.join("; ")}\n`);
}
process.stdout.write(` install: npx clawhub@latest install ${skill.id}\n`);
}
}
async function discoverCatalog() {
const indexUrl = process.env.CLAWSEC_SKILLS_INDEX_URL || DEFAULT_INDEX_URL;
const timeoutMs = parseTimeoutMs();
const fallback = await loadFallbackCatalog();
try {
const remote = await loadRemoteCatalog(indexUrl, timeoutMs);
return {
source: "remote",
index_url: indexUrl,
version: remote.version,
updated: remote.updated,
skills: mergeWithFallbackMetadata(remote.skills, fallback.skills),
warning: null,
};
} catch (error) {
return {
source: "fallback",
index_url: indexUrl,
version: fallback.version,
updated: fallback.updated,
skills: fallback.skills,
warning: String(error),
};
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const result = await discoverCatalog();
if (args.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
printHumanSummary(result);
}
main().catch((error) => {
process.stderr.write(`${String(error)}\n`);
process.exit(1);
});
+5
View File
@@ -120,6 +120,11 @@
"required": true,
"description": "Two-step confirmation installer with signature verification that blocks risky skill installs"
},
{
"path": "scripts/discover_skill_catalog.mjs",
"required": true,
"description": "Dynamic skill-catalog discovery with remote index fetch and suite-local fallback metadata"
},
{
"path": "scripts/sign_detached_ed25519.mjs",
"required": false,
@@ -0,0 +1,250 @@
#!/usr/bin/env node
/**
* Dynamic skill catalog discovery tests for clawsec-suite.
*
* Tests cover:
* - Remote index fetch and normalization
* - Enrichment with suite-local metadata (non-breaking compatibility)
* - Fallback behavior when remote index is invalid/unavailable
*
* Run: node skills/clawsec-suite/test/skill_catalog_discovery.test.mjs
*/
import http from "node:http";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SCRIPT_PATH = path.resolve(__dirname, "..", "scripts", "discover_skill_catalog.mjs");
let passCount = 0;
let failCount = 0;
function pass(name) {
passCount += 1;
console.log(`${name}`);
}
function fail(name, error) {
failCount += 1;
console.error(`${name}`);
console.error(` ${String(error)}`);
}
function runCatalogScript(args, env = {}) {
return new Promise((resolve) => {
const proc = spawn("node", [SCRIPT_PATH, ...args], {
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
proc.on("close", (code) => {
resolve({ code, stdout, stderr });
});
});
}
function withServer(handler) {
return new Promise((resolve, reject) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (!addr || typeof addr === "string") {
reject(new Error("Failed to bind test server"));
return;
}
resolve({
url: `http://127.0.0.1:${addr.port}`,
close: () =>
new Promise((done) => {
server.close(() => done());
}),
});
});
server.on("error", reject);
});
}
// -----------------------------------------------------------------------------
// Test: remote index is used when valid
// -----------------------------------------------------------------------------
async function testRemoteCatalogSuccess() {
const testName = "discover_skill_catalog: uses remote index when valid";
let fixture = null;
try {
fixture = await withServer((req, res) => {
if (req.url !== "/index.json") {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
version: "1.0.0",
updated: "2026-02-16T08:20:00Z",
skills: [
{
id: "soul-guardian",
name: "soul-guardian",
version: "9.9.9",
description: "Remote skill metadata",
emoji: "👻",
category: "security",
tag: "soul-guardian-v9.9.9",
},
{
id: "clawtributor",
name: "clawtributor",
version: "1.2.3",
description: "Remote clawtributor metadata",
emoji: "🤝",
category: "security",
tag: "clawtributor-v1.2.3",
},
],
}),
);
});
const result = await runCatalogScript(["--json"], {
CLAWSEC_SKILLS_INDEX_URL: `${fixture.url}/index.json`,
CLAWSEC_SKILLS_INDEX_TIMEOUT_MS: "2000",
});
if (result.code !== 0) {
fail(testName, `Expected exit 0, got ${result.code}: ${result.stderr}`);
return;
}
const payload = JSON.parse(result.stdout);
const clawtributor = payload.skills.find((entry) => entry.id === "clawtributor");
const soulGuardian = payload.skills.find((entry) => entry.id === "soul-guardian");
if (
payload.source === "remote" &&
payload.updated === "2026-02-16T08:20:00Z" &&
soulGuardian?.version === "9.9.9" &&
clawtributor?.requires_explicit_consent === true
) {
pass(testName);
} else {
fail(testName, `Unexpected payload: ${result.stdout}`);
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) {
await fixture.close();
}
}
}
// -----------------------------------------------------------------------------
// Test: invalid remote payload falls back to suite-local catalog
// -----------------------------------------------------------------------------
async function testInvalidRemotePayloadFallsBack() {
const testName = "discover_skill_catalog: invalid remote payload falls back";
let fixture = null;
try {
fixture = await withServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ version: "1.0.0", note: "missing skills" }));
});
const result = await runCatalogScript(["--json"], {
CLAWSEC_SKILLS_INDEX_URL: `${fixture.url}/index.json`,
CLAWSEC_SKILLS_INDEX_TIMEOUT_MS: "2000",
});
if (result.code !== 0) {
fail(testName, `Expected exit 0, got ${result.code}: ${result.stderr}`);
return;
}
const payload = JSON.parse(result.stdout);
const hasSoulGuardian = Array.isArray(payload.skills)
? payload.skills.some((entry) => entry.id === "soul-guardian")
: false;
if (payload.source === "fallback" && hasSoulGuardian && String(payload.warning).includes("skills array")) {
pass(testName);
} else {
fail(testName, `Unexpected payload: ${result.stdout}`);
}
} catch (error) {
fail(testName, error);
} finally {
if (fixture) {
await fixture.close();
}
}
}
// -----------------------------------------------------------------------------
// Test: unreachable remote index falls back to suite-local catalog
// -----------------------------------------------------------------------------
async function testUnreachableRemoteFallsBack() {
const testName = "discover_skill_catalog: unreachable remote index falls back";
try {
const result = await runCatalogScript(["--json"], {
CLAWSEC_SKILLS_INDEX_URL: "http://127.0.0.1:9/index.json",
CLAWSEC_SKILLS_INDEX_TIMEOUT_MS: "250",
});
if (result.code !== 0) {
fail(testName, `Expected exit 0, got ${result.code}: ${result.stderr}`);
return;
}
const payload = JSON.parse(result.stdout);
if (payload.source === "fallback" && Array.isArray(payload.skills) && payload.skills.length > 0) {
pass(testName);
} else {
fail(testName, `Unexpected payload: ${result.stdout}`);
}
} catch (error) {
fail(testName, error);
}
}
// -----------------------------------------------------------------------------
// Main test runner
// -----------------------------------------------------------------------------
async function runTests() {
console.log("=== ClawSec Skill Catalog Discovery Tests ===\n");
await testRemoteCatalogSuccess();
await testInvalidRemotePayloadFallsBack();
await testUnreachableRemoteFallsBack();
console.log(`\n=== Results: ${passCount} passed, ${failCount} failed ===`);
if (failCount > 0) {
process.exit(1);
}
}
runTests().catch((error) => {
console.error("Test runner failed:", error);
process.exit(1);
});