Coverage for agentos/server/marketplace_platform.py: 0%

360 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 09:59 +0800

1""" 

2AgentOS Skill Marketplace Platform (v1.8.1) 

3 

4Full-stack developer marketplace: 

5 - User registration & JWT authentication 

6 - Skill upload with manifest validation 

7 - Automated security scanning (dangerous imports, shell injection, obfuscation) 

8 - Admin review queue (approve/reject with reason) 

9 - Public skill browsing with search/filter 

10 - Skill download & version management 

11 - GitHub-style developer profiles 

12 

13Tech: FastAPI + SQLite + JWT + bcrypt 

14""" 

15 

16from __future__ import annotations 

17 

18import hashlib 

19import json 

20import os 

21import re 

22import secrets 

23import shutil 

24import sqlite3 

25import tempfile 

26import time 

27import zipfile 

28from datetime import datetime, timedelta 

29from pathlib import Path 

30from typing import Optional, List, Dict, Any 

31 

32import jwt as pyjwt 

33try: 

34 from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form, Depends, Request 

35 from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, StreamingResponse 

36 from fastapi.staticfiles import StaticFiles 

37 from fastapi.middleware.cors import CORSMiddleware 

38 from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials 

39except ImportError: 

40 raise RuntimeError("FastAPI, pyjwt required. Run: pip install fastapi uvicorn pyjwt") 

41 

42try: 

43 import bcrypt 

44except ImportError: 

45 bcrypt = None 

46 

47STATIC_DIR = Path(__file__).parent / "static" 

48PLATFORM_DIR = Path.home() / ".agentos" / "marketplace" 

49PLATFORM_DIR.mkdir(parents=True, exist_ok=True) 

50DB_PATH = PLATFORM_DIR / "platform.db" 

51UPLOAD_DIR = PLATFORM_DIR / "uploads" 

52UPLOAD_DIR.mkdir(parents=True, exist_ok=True) 

53 

54JWT_SECRET = os.environ.get("MARKETPLACE_SECRET", secrets.token_hex(32)) 

55JWT_ALGORITHM = "HS256" 

56JWT_EXPIRY_HOURS = 72 

57 

58 

59# ── Database ───────────────────────────────── 

60 

61def get_db() -> sqlite3.Connection: 

62 conn = sqlite3.connect(str(DB_PATH)) 

63 conn.row_factory = sqlite3.Row 

64 conn.execute("PRAGMA journal_mode=WAL") 

65 conn.execute("PRAGMA foreign_keys=ON") 

66 return conn 

67 

68 

69def init_db(): 

70 db = get_db() 

71 db.executescript(""" 

72 CREATE TABLE IF NOT EXISTS users ( 

73 id INTEGER PRIMARY KEY AUTOINCREMENT, 

74 username TEXT UNIQUE NOT NULL, 

75 email TEXT UNIQUE NOT NULL, 

76 password_hash TEXT NOT NULL, 

77 display_name TEXT, 

78 avatar_url TEXT, 

79 github_username TEXT, 

80 role TEXT DEFAULT 'developer', 

81 is_admin INTEGER DEFAULT 0, 

82 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 

83 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 

84 ); 

85 CREATE TABLE IF NOT EXISTS skills ( 

86 id INTEGER PRIMARY KEY AUTOINCREMENT, 

87 author_id INTEGER NOT NULL REFERENCES users(id), 

88 name TEXT NOT NULL, 

89 version TEXT NOT NULL DEFAULT '0.1.0', 

90 description TEXT, 

91 category TEXT DEFAULT 'uncategorized', 

92 tags TEXT DEFAULT '[]', 

93 format TEXT DEFAULT 'agentos', 

94 entrypoint TEXT, 

95 manifest_json TEXT, 

96 file_path TEXT, 

97 file_size INTEGER, 

98 file_hash TEXT, 

99 download_count INTEGER DEFAULT 0, 

100 status TEXT DEFAULT 'pending', 

101 security_score INTEGER, 

102 security_report TEXT, 

103 review_comment TEXT, 

104 reviewed_by INTEGER REFERENCES users(id), 

105 reviewed_at TIMESTAMP, 

106 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 

107 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 

108 UNIQUE(name, author_id) 

109 ); 

110 CREATE TABLE IF NOT EXISTS reviews ( 

111 id INTEGER PRIMARY KEY AUTOINCREMENT, 

112 skill_id INTEGER NOT NULL REFERENCES skills(id), 

113 user_id INTEGER NOT NULL REFERENCES users(id), 

114 rating INTEGER CHECK(rating >= 1 AND rating <= 5), 

115 comment TEXT, 

116 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 

117 ); 

118 CREATE TABLE IF NOT EXISTS api_tokens ( 

119 id INTEGER PRIMARY KEY AUTOINCREMENT, 

120 user_id INTEGER NOT NULL REFERENCES users(id), 

121 token_hash TEXT NOT NULL, 

122 name TEXT, 

123 last_used TIMESTAMP, 

124 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 

125 ); 

126 """) 

127 # Ensure admin user exists 

128 admin = db.execute("SELECT id FROM users WHERE username = ?", ("admin",)).fetchone() 

129 if not admin: 

130 _hash = _hash_password("admin123") 

131 db.execute( 

132 "INSERT INTO users (username, email, password_hash, role, is_admin) VALUES (?,?,?,?,?)", 

133 ("admin", "admin@agentos.dev", _hash, "admin", 1), 

134 ) 

135 db.commit() 

136 db.close() 

137 

138 

139def _hash_password(password: str) -> str: 

140 if bcrypt: 

141 return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") 

142 return hashlib.sha256(f"agentos:{password}".encode()).hexdigest() 

143 

144 

145def _verify_password(password: str, password_hash: str) -> bool: 

146 if bcrypt and password_hash.startswith("$2"): 

147 return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) 

148 return _hash_password(password) == password_hash 

149 

150 

151# ── Security Scanner ───────────────────────── 

152 

153class SecurityScanner: 

154 """Scans uploaded skill packages for security issues.""" 

155 

156 DANGEROUS_IMPORTS = { 

157 "os.system", "subprocess", "eval(", "exec(", "compile(", 

158 "__import__", "importlib", "builtins", "ctypes", 

159 "socket", "requests", "urllib", "http.client", 

160 "shutil.rmtree", "shutil.copy", "pathlib.Path.unlink", 

161 "pickle", "marshal", "dill", 

162 } 

163 

164 DANGEROUS_SHELL = {" rm ", "rm -rf", "sudo ", "chmod 777", "chown ", 

165 " | sh", " | bash", "$(", "`", "; rm", "wget ", "curl ", 

166 "/dev/null", "> /etc/", "> ~/.ssh/"} 

167 

168 OBFUSCATION_SIGNALS = {"base64.b64decode", "base64.b64encode", "exec(base64", 

169 "decode('utf-8')", "eval(compile", "globals()", "__builtins__", 

170 "lambda.*exec", "lambda.*eval", "getattr.*__"} 

171 

172 @classmethod 

173 def scan_zip(cls, zip_path: str) -> Dict[str, Any]: 

174 """Scan a skill zip package and return security report.""" 

175 findings = [] 

176 score = 100 

177 files_scanned = 0 

178 total_size = 0 

179 

180 try: 

181 with zipfile.ZipFile(zip_path, "r") as zf: 

182 for info in zf.infolist(): 

183 if info.is_dir(): 

184 continue 

185 total_size += info.file_size 

186 name = info.filename.lower() 

187 

188 # Skip binary files 

189 if any(name.endswith(ext) for ext in (".pyc", ".so", ".dll", ".exe", ".png", ".jpg", ".ico")): 

190 continue 

191 

192 try: 

193 content = zf.read(info.filename).decode("utf-8", errors="replace") 

194 files_scanned += 1 

195 except Exception: 

196 continue 

197 

198 lines = content.split("\n") 

199 

200 # Check for dangerous imports 

201 for imp in cls.DANGEROUS_IMPORTS: 

202 if imp in content: 

203 findings.append({"file": info.filename, "severity": "high", 

204 "rule": f"dangerous_import:{imp}", "line": content.find(imp)}) 

205 score -= 20 

206 

207 # Check for shell injection 

208 for pat in cls.DANGEROUS_SHELL: 

209 if pat in content: 

210 findings.append({"file": info.filename, "severity": "critical", 

211 "rule": f"shell_injection:{pat.strip()}"}) 

212 score -= 30 

213 

214 # Check for obfuscation 

215 for pat in cls.OBFUSCATION_SIGNALS: 

216 if re.search(pat, content): 

217 findings.append({"file": info.filename, "severity": "medium", 

218 "rule": f"obfuscation:{pat}"}) 

219 score -= 15 

220 

221 # Check for hardcoded secrets 

222 if re.search(r'(api_key|secret|password|token)\s*[:=]\s*["\'][a-zA-Z0-9_\-]{20,}', content): 

223 findings.append({"file": info.filename, "severity": "high", 

224 "rule": "hardcoded_secret"}) 

225 score -= 25 

226 except zipfile.BadZipFile: 

227 return {"score": 0, "findings": [{"severity": "critical", "rule": "invalid_zip"}]} 

228 

229 return { 

230 "score": max(0, score), 

231 "findings": findings, 

232 "files_scanned": files_scanned, 

233 "total_size": total_size, 

234 "risk_level": "low" if score >= 80 else "medium" if score >= 50 else "high" if score >= 20 else "critical", 

235 } 

236 

237 @classmethod 

238 def validate_manifest(cls, manifest: Dict) -> List[str]: 

239 """Validate skill manifest structure. Returns list of errors.""" 

240 errors = [] 

241 required = ["name", "version", "description"] 

242 for field in required: 

243 if not manifest.get(field): 

244 errors.append(f"Missing required field: {field}") 

245 

246 if "name" in manifest: 

247 name = manifest["name"] 

248 if not re.match(r'^[a-zA-Z][a-zA-Z0-9_\-]*$', name): 

249 errors.append(f"Invalid skill name: {name}. Use alphanumeric, hyphens, underscores.") 

250 

251 if "version" in manifest: 

252 version = manifest["version"] 

253 if not re.match(r'^\d+\.\d+\.\d+$', version): 

254 errors.append(f"Invalid version format: {version}. Use semver (e.g., 0.1.0).") 

255 

256 return errors 

257 

258 

259# ── Auth Utilities ─────────────────────────── 

260 

261security_scheme = HTTPBearer(auto_error=False) 

262 

263 

264def create_token(user_id: int, username: str, is_admin: bool) -> str: 

265 payload = { 

266 "user_id": user_id, 

267 "username": username, 

268 "is_admin": is_admin, 

269 "exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRY_HOURS), 

270 "iat": datetime.utcnow(), 

271 } 

272 return pyjwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) 

273 

274 

275def decode_token(token: str) -> Optional[Dict]: 

276 try: 

277 return pyjwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) 

278 except Exception: 

279 return None 

280 

281 

282async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme)): 

283 if not credentials: 

284 raise HTTPException(status_code=401, detail="Authentication required") 

285 payload = decode_token(credentials.credentials) 

286 if not payload: 

287 raise HTTPException(status_code=401, detail="Invalid or expired token") 

288 db = get_db() 

289 user = db.execute("SELECT * FROM users WHERE id = ?", (payload["user_id"],)).fetchone() 

290 db.close() 

291 if not user: 

292 raise HTTPException(status_code=401, detail="User not found") 

293 return dict(user) 

294 

295 

296async def get_admin_user(user: dict = Depends(get_current_user)): 

297 if not user.get("is_admin"): 

298 raise HTTPException(status_code=403, detail="Admin privileges required") 

299 return user 

300 

301 

302# ── FastAPI App ────────────────────────────── 

303 

304def create_marketplace_app() -> FastAPI: 

305 init_db() 

306 

307 app = FastAPI( 

308 title="AgentOS Skill Marketplace", 

309 version="1.8.1", 

310 description="Open developer marketplace for AgentOS skills. Upload, review, and discover AI agent skills.", 

311 ) 

312 

313 app.add_middleware( 

314 CORSMiddleware, 

315 allow_origins=["*"], 

316 allow_methods=["*"], 

317 allow_headers=["*"], 

318 ) 

319 

320 if STATIC_DIR.exists(): 

321 app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") 

322 

323 # ── Web UI ── 

324 @app.get("/", response_class=HTMLResponse) 

325 async def web_ui(): 

326 html_path = STATIC_DIR / "platform.html" 

327 if html_path.exists(): 

328 return HTMLResponse(html_path.read_text(encoding="utf-8")) 

329 return HTMLResponse("<h1>Marketplace Platform</h1>", status_code=404) 

330 

331 # ── Auth Endpoints ── 

332 

333 @app.post("/api/auth/register") 

334 async def register(username: str = Form(...), email: str = Form(...), 

335 password: str = Form(...), display_name: str = Form("")): 

336 if len(password) < 6: 

337 raise HTTPException(400, "Password must be at least 6 characters") 

338 if not re.match(r'^[a-zA-Z0-9_]{3,30}$', username): 

339 raise HTTPException(400, "Username: 3-30 chars, alphanumeric/underscore") 

340 

341 db = get_db() 

342 existing = db.execute("SELECT id FROM users WHERE username=? OR email=?", 

343 (username, email)).fetchone() 

344 if existing: 

345 db.close() 

346 raise HTTPException(409, "Username or email already exists") 

347 

348 pw_hash = _hash_password(password) 

349 try: 

350 db.execute("INSERT INTO users (username, email, password_hash, display_name) VALUES (?,?,?,?)", 

351 (username, email, pw_hash, display_name or username)) 

352 db.commit() 

353 user_id = db.lastrowid 

354 finally: 

355 db.close() 

356 

357 token = create_token(user_id, username, False) 

358 return {"token": token, "user": {"id": user_id, "username": username, "email": email, "is_admin": False}} 

359 

360 @app.post("/api/auth/login") 

361 async def login(username: str = Form(...), password: str = Form(...)): 

362 db = get_db() 

363 user = db.execute("SELECT * FROM users WHERE username = ? OR email = ?", 

364 (username, username)).fetchone() 

365 if not user or not _verify_password(password, user["password_hash"]): 

366 db.close() 

367 raise HTTPException(401, "Invalid credentials") 

368 db.close() 

369 

370 token = create_token(user["id"], user["username"], bool(user["is_admin"])) 

371 return { 

372 "token": token, 

373 "user": { 

374 "id": user["id"], "username": user["username"], "email": user["email"], 

375 "display_name": user["display_name"], "is_admin": bool(user["is_admin"]), 

376 "github_username": user["github_username"], 

377 } 

378 } 

379 

380 @app.get("/api/auth/me") 

381 async def me(user: dict = Depends(get_current_user)): 

382 return {"user": {k: v for k, v in user.items() if k != "password_hash"}} 

383 

384 # ── Skill Upload ── 

385 

386 @app.post("/api/skills/upload") 

387 async def upload_skill( 

388 file: UploadFile = File(...), 

389 name: str = Form(""), 

390 version: str = Form("0.1.0"), 

391 description: str = Form(""), 

392 category: str = Form("uncategorized"), 

393 tags: str = Form("[]"), 

394 user: dict = Depends(get_current_user), 

395 ): 

396 """Upload a skill package (.zip containing skill files + manifest.json).""" 

397 if not file.filename or not file.filename.endswith(".zip"): 

398 raise HTTPException(400, "Only .zip files are accepted") 

399 

400 # Save uploaded file 

401 ts = int(time.time()) 

402 safe_name = re.sub(r'[^a-zA-Z0-9_.-]', '_', file.filename) 

403 file_id = f"{user['id']}_{ts}_{safe_name}" 

404 file_path = UPLOAD_DIR / file_id 

405 content = await file.read() 

406 file_path.write_bytes(content) 

407 

408 # Validate zip 

409 if not zipfile.is_zipfile(str(file_path)): 

410 file_path.unlink() 

411 raise HTTPException(400, "Invalid zip file") 

412 

413 # Extract and validate manifest 

414 manifest = {} 

415 with zipfile.ZipFile(str(file_path)) as zf: 

416 if "skill.yaml" in zf.namelist(): 

417 manifest_text = zf.read("skill.yaml").decode("utf-8") 

418 manifest = _parse_yaml_simple(manifest_text) 

419 elif "skill.json" in zf.namelist(): 

420 manifest = json.loads(zf.read("skill.json")) 

421 elif "manifest.json" in zf.namelist(): 

422 manifest = json.loads(zf.read("manifest.json")) 

423 

424 # Use form fields as fallback 

425 skill_name = manifest.get("name") or name or file.filename.replace(".zip", "") 

426 skill_version = manifest.get("version") or version 

427 skill_desc = manifest.get("description") or description 

428 skill_category = manifest.get("category") or category 

429 skill_tags = manifest.get("tags", []) if isinstance(manifest.get("tags"), list) else [] 

430 

431 # Use form tags if manifest has none 

432 if not skill_tags: 

433 try: 

434 skill_tags = json.loads(tags) if isinstance(tags, str) else tags 

435 except json.JSONDecodeError: 

436 skill_tags = [] 

437 

438 # Validate 

439 manifest_errors = SecurityScanner.validate_manifest({ 

440 "name": skill_name, "version": skill_version, "description": skill_desc, 

441 }) 

442 if manifest_errors: 

443 file_path.unlink() 

444 raise HTTPException(400, f"Manifest validation failed: {'; '.join(manifest_errors)}") 

445 

446 # Security scan 

447 security = SecurityScanner.scan_zip(str(file_path)) 

448 auto_status = "published" if security["risk_level"] == "low" else "flagged" 

449 if security["score"] <= 20: 

450 auto_status = "rejected" 

451 file_path.unlink() 

452 raise HTTPException(400, f"Security scan failed (score: {security['score']}/100). " 

453 f"Risk: {security['risk_level']}. Findings: {len(security['findings'])}") 

454 

455 # Compute hash 

456 file_hash = hashlib.sha256(content).hexdigest() 

457 

458 db = get_db() 

459 try: 

460 db.execute( 

461 """INSERT INTO skills (author_id, name, version, description, category, tags, 

462 format, entrypoint, manifest_json, file_path, file_size, file_hash, 

463 status, security_score, security_report) 

464 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", 

465 (user["id"], skill_name, skill_version, skill_desc, skill_category, 

466 json.dumps(skill_tags), manifest.get("format", "agentos"), 

467 manifest.get("entrypoint", ""), json.dumps(manifest, ensure_ascii=False), 

468 str(file_path.absolute()), os.path.getsize(str(file_path)), file_hash, 

469 auto_status, security["score"], json.dumps(security, ensure_ascii=False)), 

470 ) 

471 db.commit() 

472 skill_id = db.lastrowid 

473 except sqlite3.IntegrityError: 

474 file_path.unlink() 

475 db.close() 

476 raise HTTPException(409, "You already have a skill with this name") 

477 db.close() 

478 

479 return { 

480 "id": skill_id, "name": skill_name, "version": skill_version, 

481 "status": auto_status, "security_score": security["score"], 

482 "risk_level": security["risk_level"], 

483 "findings_count": len(security["findings"]), 

484 } 

485 

486 # ── Public Browse ── 

487 

488 @app.get("/api/skills") 

489 async def list_skills( 

490 q: str = Query(""), 

491 category: str = Query(""), 

492 status: str = Query("published"), 

493 sort: str = Query("downloads"), 

494 page: int = Query(1), 

495 limit: int = Query(30), 

496 ): 

497 db = get_db() 

498 where = ["s.status = ?"] 

499 params: list = [status] 

500 

501 if q: 

502 where.append("(s.name LIKE ? OR s.description LIKE ?)") 

503 params.extend([f"%{q}%", f"%{q}%"]) 

504 if category: 

505 where.append("s.category = ?") 

506 params.append(category) 

507 

508 order = "s.download_count DESC" if sort == "downloads" else "s.created_at DESC" 

509 offset = (page - 1) * limit 

510 

511 skills = db.execute( 

512 f"""SELECT s.*, u.username as author_name, u.display_name as author_display 

513 FROM skills s JOIN users u ON s.author_id = u.id 

514 WHERE {' AND '.join(where)} 

515 ORDER BY {order} 

516 LIMIT ? OFFSET ?""", 

517 params + [limit, offset], 

518 ).fetchall() 

519 

520 total = db.execute( 

521 f"SELECT COUNT(*) FROM skills s WHERE {' AND '.join(where)}", params 

522 ).fetchone()[0] 

523 db.close() 

524 

525 return { 

526 "total": total, "page": page, "limit": limit, 

527 "skills": [ 

528 {"id": s["id"], "name": s["name"], "version": s["version"], 

529 "description": s["description"], "category": s["category"], 

530 "tags": json.loads(s["tags"]), "format": s["format"], 

531 "download_count": s["download_count"], "status": s["status"], 

532 "security_score": s["security_score"], 

533 "author": {"username": s["author_name"], "display_name": s["author_display"]}, 

534 "created_at": s["created_at"]} 

535 for s in skills 

536 ], 

537 } 

538 

539 @app.get("/api/skills/{skill_id}") 

540 async def get_skill_detail(skill_id: int): 

541 db = get_db() 

542 skill = db.execute( 

543 """SELECT s.*, u.username as author_name, u.display_name as author_display 

544 FROM skills s JOIN users u ON s.author_id = u.id 

545 WHERE s.id = ? AND s.status = 'published'""", 

546 (skill_id,), 

547 ).fetchone() 

548 if not skill: 

549 db.close() 

550 raise HTTPException(404, "Skill not found") 

551 

552 # Get review stats 

553 review_stats = db.execute( 

554 "SELECT COUNT(*) as count, AVG(rating) as avg_rating FROM reviews WHERE skill_id = ?", 

555 (skill_id,), 

556 ).fetchone() 

557 db.close() 

558 

559 return { 

560 "id": skill["id"], "name": skill["name"], "version": skill["version"], 

561 "description": skill["description"], "category": skill["category"], 

562 "tags": json.loads(skill["tags"]), "format": skill["format"], 

563 "entrypoint": skill["entrypoint"], "manifest": json.loads(skill["manifest_json"] or "{}"), 

564 "download_count": skill["download_count"], "status": skill["status"], 

565 "security_score": skill["security_score"], 

566 "author": {"username": skill["author_name"], "display_name": skill["author_display"]}, 

567 "created_at": skill["created_at"], 

568 "reviews": {"count": review_stats["count"] or 0, 

569 "avg_rating": round(review_stats["avg_rating"] or 0, 1)}, 

570 } 

571 

572 # ── Download ── 

573 

574 @app.get("/api/skills/{skill_id}/download") 

575 async def download_skill(skill_id: int): 

576 db = get_db() 

577 skill = db.execute( 

578 "SELECT * FROM skills WHERE id = ? AND status = 'published'", (skill_id,) 

579 ).fetchone() 

580 if not skill: 

581 db.close() 

582 raise HTTPException(404, "Skill not found") 

583 

584 db.execute("UPDATE skills SET download_count = download_count + 1 WHERE id = ?", (skill_id,)) 

585 db.commit() 

586 db.close() 

587 

588 file_path = Path(skill["file_path"]) 

589 if not file_path.exists(): 

590 raise HTTPException(404, "Skill file not found on server") 

591 

592 return FileResponse( 

593 path=str(file_path), 

594 filename=f"{skill['name']}-{skill['version']}.zip", 

595 media_type="application/zip", 

596 ) 

597 

598 # ── Admin: Review Queue ── 

599 

600 @app.get("/api/admin/review-queue") 

601 async def review_queue(user: dict = Depends(get_admin_user)): 

602 db = get_db() 

603 skills = db.execute( 

604 """SELECT s.*, u.username as author_name 

605 FROM skills s JOIN users u ON s.author_id = u.id 

606 WHERE s.status IN ('pending', 'flagged') 

607 ORDER BY s.created_at DESC""" 

608 ).fetchall() 

609 db.close() 

610 return { 

611 "count": len(skills), 

612 "skills": [ 

613 {"id": s["id"], "name": s["name"], "version": s["version"], 

614 "description": s["description"], "category": s["category"], 

615 "status": s["status"], "security_score": s["security_score"], 

616 "security_report": json.loads(s["security_report"] or "{}"), 

617 "author": s["author_name"], "created_at": s["created_at"]} 

618 for s in skills 

619 ], 

620 } 

621 

622 @app.post("/api/admin/review/{skill_id}") 

623 async def review_skill( 

624 skill_id: int, 

625 action: str = Form(...), # "approve" or "reject" 

626 comment: str = Form(""), 

627 user: dict = Depends(get_admin_user), 

628 ): 

629 if action not in ("approve", "reject"): 

630 raise HTTPException(400, "Action must be 'approve' or 'reject'") 

631 

632 db = get_db() 

633 skill = db.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone() 

634 if not skill: 

635 db.close() 

636 raise HTTPException(404, "Skill not found") 

637 

638 new_status = "published" if action == "approve" else "rejected" 

639 db.execute( 

640 "UPDATE skills SET status=?, review_comment=?, reviewed_by=?, reviewed_at=CURRENT_TIMESTAMP WHERE id=?", 

641 (new_status, comment, user["id"], skill_id), 

642 ) 

643 db.commit() 

644 db.close() 

645 

646 return {"id": skill_id, "status": new_status, "action": action} 

647 

648 # ── Categories ── 

649 

650 @app.get("/api/categories") 

651 async def list_categories(): 

652 db = get_db() 

653 cats = db.execute( 

654 "SELECT category, COUNT(*) as count FROM skills WHERE status='published' GROUP BY category ORDER BY count DESC" 

655 ).fetchall() 

656 db.close() 

657 return {"categories": [{"name": c["category"], "count": c["count"]} for c in cats]} 

658 

659 # ── Developer Profile ── 

660 

661 @app.get("/api/developers/{username}") 

662 async def developer_profile(username: str): 

663 db = get_db() 

664 user = db.execute("SELECT id, username, display_name, avatar_url, github_username, created_at FROM users WHERE username = ?", (username,)).fetchone() 

665 if not user: 

666 db.close() 

667 raise HTTPException(404, "Developer not found") 

668 

669 skills = db.execute( 

670 "SELECT id, name, version, description, category, tags, download_count, status, created_at FROM skills WHERE author_id = ? AND status = 'published' ORDER BY download_count DESC", 

671 (user["id"],), 

672 ).fetchall() 

673 db.close() 

674 

675 return { 

676 "developer": dict(user), 

677 "skills": [dict(s) for s in skills], 

678 "total_skills": len(skills), 

679 "total_downloads": sum(s["download_count"] for s in skills), 

680 } 

681 

682 # ── My Skills ── 

683 

684 @app.get("/api/my/skills") 

685 async def my_skills(user: dict = Depends(get_current_user)): 

686 db = get_db() 

687 skills = db.execute( 

688 "SELECT * FROM skills WHERE author_id = ? ORDER BY created_at DESC", 

689 (user["id"],), 

690 ).fetchall() 

691 db.close() 

692 return {"skills": [dict(s) for s in skills]} 

693 

694 # ── Health ── 

695 

696 @app.get("/api/health") 

697 async def health_check(): 

698 db = get_db() 

699 skill_count = db.execute("SELECT COUNT(*) FROM skills WHERE status='published'").fetchone()[0] 

700 user_count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0] 

701 db.close() 

702 return { 

703 "status": "healthy", 

704 "version": "1.8.1", 

705 "published_skills": skill_count, 

706 "registered_developers": user_count, 

707 } 

708 

709 return app 

710 

711 

712def _parse_yaml_simple(text: str) -> Dict[str, Any]: 

713 """Simple YAML parser for skill manifests. Handles basic key: value + lists.""" 

714 result: Dict[str, Any] = {} 

715 current_key = None 

716 for line in text.split("\n"): 

717 stripped = line.strip() 

718 if not stripped or stripped.startswith("#"): 

719 continue 

720 if ":" in stripped and not stripped.startswith("- "): 

721 key, _, val = stripped.partition(":") 

722 key = key.strip() 

723 val = val.strip().strip('"').strip("'") 

724 if val: 

725 result[key] = val 

726 else: 

727 result[key] = [] 

728 current_key = key 

729 elif stripped.startswith("- ") and current_key: 

730 item = stripped[2:].strip().strip('"').strip("'") 

731 result[current_key].append(item) 

732 return result 

733 

734 

735def start_marketplace_platform(host: str = "0.0.0.0", port: int = 8911) -> None: 

736 """Start the marketplace platform server (blocking).""" 

737 import uvicorn 

738 app = create_marketplace_app() 

739 print(f"\n AgentOS Skill Marketplace Platform v1.8.1") 

740 print(f" Local: http://{host}:{port}") 

741 print(f" Admin: admin / admin123") 

742 print(f" Upload: POST /api/skills/upload | Browse: GET /api/skills") 

743 print() 

744 uvicorn.run(app, host=host, port=port, log_level="info")