skillhub/scripts/tests/scanner-llm-base-url-test.sh
dongmucat bf7c71ad2c fix(scanner): backport local LLM base URL handling for #563
Also add Python CodeQL coverage in the security workflow so repository-level script regression checks stay green when Python source exists.

Signed-off-by: dongmucat <1127093059@qq.com>
2026-06-29 14:34:55 +08:00

232 lines
6.9 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCANNER_DIR="$REPO_ROOT/scanner"
TMP_DIRS=()
cleanup() {
local status=$?
local d
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
rm -rf "$d"
done
exit "$status"
}
trap cleanup EXIT
new_tmp() {
local d
d="$(mktemp -d)"
TMP_DIRS+=("$d")
echo "$d"
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
tmp="$(new_tmp)"
skill_dir="$tmp/skill"
mkdir -p "$skill_dir/demo-skill"
cat >"$skill_dir/demo-skill/SKILL.md" <<'EOF'
---
name: demo-skill
description: Minimal valid skill used for scanner integration coverage.
license: Apache-2.0
---
This is a harmless demo skill used for scanner integration testing.
EOF
cat >"$skill_dir/demo-skill/run.sh" <<'EOF'
#!/usr/bin/env sh
echo "demo"
EOF
chmod +x "$skill_dir/demo-skill/run.sh"
IMAGE_TAG="skillhub-scanner-llm-base-url-test:$(date +%s)"
docker build --no-cache -t "$IMAGE_TAG" "$SCANNER_DIR" >/dev/null
docker run --rm -i \
-v "$skill_dir:/work/skill:ro" \
--entrypoint python \
"$IMAGE_TAG" - <<'PY'
import asyncio
from datetime import datetime, timezone
import http.server
import io
import inspect
import json
import os
from pathlib import Path
import threading
import urllib.request
import zipfile
from fastapi.params import Query
from skill_scanner.core.models import ScanResult
import skill_scanner.api.router as router
signature = inspect.signature(router.scan_uploaded_skill)
if not isinstance(signature.parameters["use_llm"].default, Query):
raise SystemExit("scan-upload use_llm should remain a Query parameter")
if not isinstance(signature.parameters["llm_provider"].default, Query):
raise SystemExit("scan-upload llm_provider should remain a Query parameter")
state = {"base_urls": [], "paths": []}
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args): # noqa: A003
return
def do_POST(self): # noqa: N802
length = int(self.headers.get("content-length", "0"))
self.rfile.read(length)
state["paths"].append(self.path)
payload = json.dumps(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": int(datetime.now(timezone.utc).timestamp()),
"model": "local-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "No findings."},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
target_base_url = f"http://127.0.0.1:{server.server_port}/v1"
os.environ["SKILL_SCANNER_LLM_BASE_URL"] = target_base_url
os.environ["SKILL_SCANNER_LLM_MODEL"] = "test-model"
class FakeStaticAnalyzer:
pass
class FakeLLMAnalyzer:
def __init__(self, model=None, provider=None, base_url=None):
self.model = model
self.provider = provider
self.base_url = base_url
state["base_urls"].append(base_url)
def analyze(self, skill_path):
request = urllib.request.Request(
self.base_url + "/chat/completions",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
response.read()
class FakeSkillScanner:
def __init__(self, analyzers):
self.analyzers = analyzers
def scan_skill(self, skill_path):
for analyzer in self.analyzers:
analyze = getattr(analyzer, "analyze", None)
if callable(analyze):
analyze(skill_path)
return ScanResult(
skill_name="demo-skill",
skill_directory=str(skill_path),
findings=[],
scan_duration_seconds=0.05,
analyzers_used=["fake-llm"],
timestamp=datetime.now(timezone.utc),
)
router.StaticAnalyzer = FakeStaticAnalyzer
router.LLMAnalyzer = FakeLLMAnalyzer
router.SkillScanner = FakeSkillScanner
router.LLM_AVAILABLE = True
request = router.ScanRequest(
skill_directory="/work/skill/demo-skill",
use_llm=True,
llm_provider="openai",
use_behavioral=False,
use_aidefense=False,
aidefense_api_key=None,
)
def build_skill_archive_bytes(skill_root: str) -> bytes:
skill_path = Path(skill_root)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in skill_path.rglob("*"):
if path.is_file():
archive.writestr(str(path.relative_to(skill_path.parent)), path.read_bytes())
return buffer.getvalue()
class FakeUploadFile:
def __init__(self, filename: str, payload: bytes):
self.filename = filename
self._payload = payload
async def read(self) -> bytes:
return self._payload
try:
direct_response = asyncio.run(router.scan_skill(request))
upload_response = asyncio.run(
router.scan_uploaded_skill(
file=FakeUploadFile("demo-skill.zip", build_skill_archive_bytes("/work/skill/demo-skill")),
use_llm=True,
llm_provider="openai",
use_behavioral=False,
use_aidefense=False,
aidefense_api_key=None,
)
)
finally:
server.shutdown()
thread.join(timeout=5)
if not getattr(direct_response, "scan_id", None):
raise SystemExit("scan_skill should still return a scan response")
if not getattr(upload_response, "scan_id", None):
raise SystemExit("scan_uploaded_skill should still return a scan response")
if len(state["base_urls"]) != 2:
raise SystemExit(f"expected two LLM analyzer constructions, got {len(state['base_urls'])}")
if any(base_url != target_base_url for base_url in state["base_urls"]):
raise SystemExit(f"expected every base_url to be {target_base_url}, got {state['base_urls']}")
if len(state["paths"]) != 2:
raise SystemExit(f"expected two LLM requests, got {state['paths']}")
if not all(path.startswith("/v1/") for path in state["paths"]):
raise SystemExit(f"expected every request path to start with /v1/, got {state['paths']}")
PY
grep -Fq "name: SKILL_SCANNER_LLM_BASE_URL" "$REPO_ROOT/deploy/k8s/base/scanner-deployment.yaml" \
|| fail "Kubernetes scanner deployment must expose SKILL_SCANNER_LLM_BASE_URL"
grep -Fq "skill-scanner-llm-base-url" "$REPO_ROOT/deploy/k8s/base/secret.yaml.example" \
|| fail "Kubernetes secret example must document skill-scanner-llm-base-url"
echo "scanner-llm-base-url-test passed"