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

358 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 10: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 sqlite3 

24import time 

25import zipfile 

26from datetime import datetime, timedelta 

27from pathlib import Path 

28from typing import Optional, List, Dict, Any 

29 

30import jwt as pyjwt 

31try: 

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

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

34 from fastapi.staticfiles import StaticFiles 

35 from fastapi.middleware.cors import CORSMiddleware 

36 from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials 

37except ImportError: 

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

39 

40try: 

41 import bcrypt 

42except ImportError: 

43 bcrypt = None 

44 

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

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

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

48DB_PATH = PLATFORM_DIR / "platform.db" 

49UPLOAD_DIR = PLATFORM_DIR / "uploads" 

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

51 

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

53JWT_ALGORITHM = "HS256" 

54JWT_EXPIRY_HOURS = 72 

55 

56 

57# ── Database ───────────────────────────────── 

58 

59def get_db() -> sqlite3.Connection: 

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

61 conn.row_factory = sqlite3.Row 

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

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

64 return conn 

65 

66 

67def init_db(): 

68 db = get_db() 

69 db.executescript(""" 

70 CREATE TABLE IF NOT EXISTS users ( 

71 id INTEGER PRIMARY KEY AUTOINCREMENT, 

72 username TEXT UNIQUE NOT NULL, 

73 email TEXT UNIQUE NOT NULL, 

74 password_hash TEXT NOT NULL, 

75 display_name TEXT, 

76 avatar_url TEXT, 

77 github_username TEXT, 

78 role TEXT DEFAULT 'developer', 

79 is_admin INTEGER DEFAULT 0, 

80 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 

81 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 

82 ); 

83 CREATE TABLE IF NOT EXISTS skills ( 

84 id INTEGER PRIMARY KEY AUTOINCREMENT, 

85 author_id INTEGER NOT NULL REFERENCES users(id), 

86 name TEXT NOT NULL, 

87 version TEXT NOT NULL DEFAULT '0.1.0', 

88 description TEXT, 

89 category TEXT DEFAULT 'uncategorized', 

90 tags TEXT DEFAULT '[]', 

91 format TEXT DEFAULT 'agentos', 

92 entrypoint TEXT, 

93 manifest_json TEXT, 

94 file_path TEXT, 

95 file_size INTEGER, 

96 file_hash TEXT, 

97 download_count INTEGER DEFAULT 0, 

98 status TEXT DEFAULT 'pending', 

99 security_score INTEGER, 

100 security_report TEXT, 

101 review_comment TEXT, 

102 reviewed_by INTEGER REFERENCES users(id), 

103 reviewed_at TIMESTAMP, 

104 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 

105 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 

106 UNIQUE(name, author_id) 

107 ); 

108 CREATE TABLE IF NOT EXISTS reviews ( 

109 id INTEGER PRIMARY KEY AUTOINCREMENT, 

110 skill_id INTEGER NOT NULL REFERENCES skills(id), 

111 user_id INTEGER NOT NULL REFERENCES users(id), 

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

113 comment TEXT, 

114 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 

115 ); 

116 CREATE TABLE IF NOT EXISTS api_tokens ( 

117 id INTEGER PRIMARY KEY AUTOINCREMENT, 

118 user_id INTEGER NOT NULL REFERENCES users(id), 

119 token_hash TEXT NOT NULL, 

120 name TEXT, 

121 last_used TIMESTAMP, 

122 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 

123 ); 

124 """) 

125 # Ensure admin user exists 

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

127 if not admin: 

128 _hash = _hash_password("admin123") 

129 db.execute( 

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

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

132 ) 

133 db.commit() 

134 db.close() 

135 

136 

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

138 if bcrypt: 

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

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

141 

142 

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

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

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

146 return _hash_password(password) == password_hash 

147 

148 

149# ── Security Scanner ───────────────────────── 

150 

151class SecurityScanner: 

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

153 

154 DANGEROUS_IMPORTS = { 

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

156 "__import__", "importlib", "builtins", "ctypes", 

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

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

159 "pickle", "marshal", "dill", 

160 } 

161 

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

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

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

165 

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

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

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

169 

170 @classmethod 

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

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

173 findings = [] 

174 score = 100 

175 files_scanned = 0 

176 total_size = 0 

177 

178 try: 

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

180 for info in zf.infolist(): 

181 if info.is_dir(): 

182 continue 

183 total_size += info.file_size 

184 name = info.filename.lower() 

185 

186 # Skip binary files 

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

188 continue 

189 

190 try: 

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

192 files_scanned += 1 

193 except Exception: 

194 continue 

195 

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

197 

198 # Check for dangerous imports 

199 for imp in cls.DANGEROUS_IMPORTS: 

200 if imp in content: 

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

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

203 score -= 20 

204 

205 # Check for shell injection 

206 for pat in cls.DANGEROUS_SHELL: 

207 if pat in content: 

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

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

210 score -= 30 

211 

212 # Check for obfuscation 

213 for pat in cls.OBFUSCATION_SIGNALS: 

214 if re.search(pat, content): 

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

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

217 score -= 15 

218 

219 # Check for hardcoded secrets 

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

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

222 "rule": "hardcoded_secret"}) 

223 score -= 25 

224 except zipfile.BadZipFile: 

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

226 

227 return { 

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

229 "findings": findings, 

230 "files_scanned": files_scanned, 

231 "total_size": total_size, 

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

233 } 

234 

235 @classmethod 

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

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

238 errors = [] 

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

240 for field in required: 

241 if not manifest.get(field): 

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

243 

244 if "name" in manifest: 

245 name = manifest["name"] 

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

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

248 

249 if "version" in manifest: 

250 version = manifest["version"] 

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

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

253 

254 return errors 

255 

256 

257# ── Auth Utilities ─────────────────────────── 

258 

259security_scheme = HTTPBearer(auto_error=False) 

260 

261 

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

263 payload = { 

264 "user_id": user_id, 

265 "username": username, 

266 "is_admin": is_admin, 

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

268 "iat": datetime.utcnow(), 

269 } 

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

271 

272 

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

274 try: 

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

276 except Exception: 

277 return None 

278 

279 

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

281 if not credentials: 

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

283 payload = decode_token(credentials.credentials) 

284 if not payload: 

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

286 db = get_db() 

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

288 db.close() 

289 if not user: 

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

291 return dict(user) 

292 

293 

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

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

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

297 return user 

298 

299 

300# ── FastAPI App ────────────────────────────── 

301 

302def create_marketplace_app() -> FastAPI: 

303 init_db() 

304 

305 app = FastAPI( 

306 title="AgentOS Skill Marketplace", 

307 version="1.8.1", 

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

309 ) 

310 

311 app.add_middleware( 

312 CORSMiddleware, 

313 allow_origins=["*"], 

314 allow_methods=["*"], 

315 allow_headers=["*"], 

316 ) 

317 

318 if STATIC_DIR.exists(): 

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

320 

321 # ── Web UI ── 

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

323 async def web_ui(): 

324 html_path = STATIC_DIR / "platform.html" 

325 if html_path.exists(): 

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

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

328 

329 # ── Auth Endpoints ── 

330 

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

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

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

334 if len(password) < 6: 

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

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

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

338 

339 db = get_db() 

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

341 (username, email)).fetchone() 

342 if existing: 

343 db.close() 

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

345 

346 pw_hash = _hash_password(password) 

347 try: 

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

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

350 db.commit() 

351 user_id = db.lastrowid 

352 finally: 

353 db.close() 

354 

355 token = create_token(user_id, username, False) 

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

357 

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

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

360 db = get_db() 

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

362 (username, username)).fetchone() 

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

364 db.close() 

365 raise HTTPException(401, "Invalid credentials") 

366 db.close() 

367 

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

369 return { 

370 "token": token, 

371 "user": { 

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

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

374 "github_username": user["github_username"], 

375 } 

376 } 

377 

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

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

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

381 

382 # ── Skill Upload ── 

383 

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

385 async def upload_skill( 

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

387 name: str = Form(""), 

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

389 description: str = Form(""), 

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

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

392 user: dict = Depends(get_current_user), 

393 ): 

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

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

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

397 

398 # Save uploaded file 

399 ts = int(time.time()) 

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

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

402 file_path = UPLOAD_DIR / file_id 

403 content = await file.read() 

404 file_path.write_bytes(content) 

405 

406 # Validate zip 

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

408 file_path.unlink() 

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

410 

411 # Extract and validate manifest 

412 manifest = {} 

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

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

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

416 manifest = _parse_yaml_simple(manifest_text) 

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

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

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

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

421 

422 # Use form fields as fallback 

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

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

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

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

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

428 

429 # Use form tags if manifest has none 

430 if not skill_tags: 

431 try: 

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

433 except json.JSONDecodeError: 

434 skill_tags = [] 

435 

436 # Validate 

437 manifest_errors = SecurityScanner.validate_manifest({ 

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

439 }) 

440 if manifest_errors: 

441 file_path.unlink() 

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

443 

444 # Security scan 

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

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

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

448 auto_status = "rejected" 

449 file_path.unlink() 

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

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

452 

453 # Compute hash 

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

455 

456 db = get_db() 

457 try: 

458 db.execute( 

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

460 format, entrypoint, manifest_json, file_path, file_size, file_hash, 

461 status, security_score, security_report) 

462 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", 

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

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

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

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

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

468 ) 

469 db.commit() 

470 skill_id = db.lastrowid 

471 except sqlite3.IntegrityError: 

472 file_path.unlink() 

473 db.close() 

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

475 db.close() 

476 

477 return { 

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

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

480 "risk_level": security["risk_level"], 

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

482 } 

483 

484 # ── Public Browse ── 

485 

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

487 async def list_skills( 

488 q: str = Query(""), 

489 category: str = Query(""), 

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

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

492 page: int = Query(1), 

493 limit: int = Query(30), 

494 ): 

495 db = get_db() 

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

497 params: list = [status] 

498 

499 if q: 

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

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

502 if category: 

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

504 params.append(category) 

505 

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

507 offset = (page - 1) * limit 

508 

509 skills = db.execute( 

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

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

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

513 ORDER BY {order} 

514 LIMIT ? OFFSET ?""", 

515 params + [limit, offset], 

516 ).fetchall() 

517 

518 total = db.execute( 

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

520 ).fetchone()[0] 

521 db.close() 

522 

523 return { 

524 "total": total, "page": page, "limit": limit, 

525 "skills": [ 

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

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

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

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

530 "security_score": s["security_score"], 

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

532 "created_at": s["created_at"]} 

533 for s in skills 

534 ], 

535 } 

536 

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

538 async def get_skill_detail(skill_id: int): 

539 db = get_db() 

540 skill = db.execute( 

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

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

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

544 (skill_id,), 

545 ).fetchone() 

546 if not skill: 

547 db.close() 

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

549 

550 # Get review stats 

551 review_stats = db.execute( 

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

553 (skill_id,), 

554 ).fetchone() 

555 db.close() 

556 

557 return { 

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

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

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

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

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

563 "security_score": skill["security_score"], 

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

565 "created_at": skill["created_at"], 

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

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

568 } 

569 

570 # ── Download ── 

571 

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

573 async def download_skill(skill_id: int): 

574 db = get_db() 

575 skill = db.execute( 

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

577 ).fetchone() 

578 if not skill: 

579 db.close() 

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

581 

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

583 db.commit() 

584 db.close() 

585 

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

587 if not file_path.exists(): 

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

589 

590 return FileResponse( 

591 path=str(file_path), 

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

593 media_type="application/zip", 

594 ) 

595 

596 # ── Admin: Review Queue ── 

597 

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

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

600 db = get_db() 

601 skills = db.execute( 

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

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

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

605 ORDER BY s.created_at DESC""" 

606 ).fetchall() 

607 db.close() 

608 return { 

609 "count": len(skills), 

610 "skills": [ 

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

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

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

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

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

616 for s in skills 

617 ], 

618 } 

619 

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

621 async def review_skill( 

622 skill_id: int, 

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

624 comment: str = Form(""), 

625 user: dict = Depends(get_admin_user), 

626 ): 

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

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

629 

630 db = get_db() 

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

632 if not skill: 

633 db.close() 

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

635 

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

637 db.execute( 

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

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

640 ) 

641 db.commit() 

642 db.close() 

643 

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

645 

646 # ── Categories ── 

647 

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

649 async def list_categories(): 

650 db = get_db() 

651 cats = db.execute( 

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

653 ).fetchall() 

654 db.close() 

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

656 

657 # ── Developer Profile ── 

658 

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

660 async def developer_profile(username: str): 

661 db = get_db() 

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

663 if not user: 

664 db.close() 

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

666 

667 skills = db.execute( 

668 "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", 

669 (user["id"],), 

670 ).fetchall() 

671 db.close() 

672 

673 return { 

674 "developer": dict(user), 

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

676 "total_skills": len(skills), 

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

678 } 

679 

680 # ── My Skills ── 

681 

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

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

684 db = get_db() 

685 skills = db.execute( 

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

687 (user["id"],), 

688 ).fetchall() 

689 db.close() 

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

691 

692 # ── Health ── 

693 

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

695 async def health_check(): 

696 db = get_db() 

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

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

699 db.close() 

700 return { 

701 "status": "healthy", 

702 "version": "1.8.1", 

703 "published_skills": skill_count, 

704 "registered_developers": user_count, 

705 } 

706 

707 return app 

708 

709 

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

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

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

713 current_key = None 

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

715 stripped = line.strip() 

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

717 continue 

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

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

720 key = key.strip() 

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

722 if val: 

723 result[key] = val 

724 else: 

725 result[key] = [] 

726 current_key = key 

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

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

729 result[current_key].append(item) 

730 return result 

731 

732 

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

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

735 import uvicorn 

736 app = create_marketplace_app() 

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

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

739 print(" Admin: admin / admin123") 

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

741 print() 

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