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

358 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 21:19 +0800

1""" 

2AgentOS Skill Marketplace Platform (v1.8.1) # noqa: E501 

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 Any 

29 

30import jwt as pyjwt 

31 

32try: 

33 from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile 

34 from fastapi.middleware.cors import CORSMiddleware 

35 from fastapi.responses import FileResponse, HTMLResponse 

36 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer 

37 from fastapi.staticfiles import StaticFiles 

38except ImportError: 

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

40 

41try: 

42 import bcrypt 

43except ImportError: 

44 bcrypt = None 

45 

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

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

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

49DB_PATH = PLATFORM_DIR / "platform.db" 

50UPLOAD_DIR = PLATFORM_DIR / "uploads" 

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

52 

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

54JWT_ALGORITHM = "HS256" 

55JWT_EXPIRY_HOURS = 72 

56 

57 

58# ── Database ───────────────────────────────── 

59 

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 

153 

154class SecurityScanner: 

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

156 

157 DANGEROUS_IMPORTS = { 

158 "os.system", 

159 "subprocess", 

160 "eval(", 

161 "exec(", 

162 "compile(", 

163 "__import__", 

164 "importlib", 

165 "builtins", 

166 "ctypes", 

167 "socket", 

168 "requests", 

169 "urllib", 

170 "http.client", 

171 "shutil.rmtree", 

172 "shutil.copy", 

173 "pathlib.Path.unlink", 

174 "pickle", 

175 "marshal", 

176 "dill", 

177 } 

178 

179 DANGEROUS_SHELL = { 

180 " rm ", 

181 "rm -rf", 

182 "sudo ", 

183 "chmod 777", 

184 "chown ", 

185 " | sh", 

186 " | bash", 

187 "$(", 

188 "`", 

189 "; rm", 

190 "wget ", 

191 "curl ", 

192 "/dev/null", 

193 "> /etc/", 

194 "> ~/.ssh/", 

195 } 

196 

197 OBFUSCATION_SIGNALS = { 

198 "base64.b64decode", 

199 "base64.b64encode", 

200 "exec(base64", 

201 "decode('utf-8')", 

202 "eval(compile", 

203 "globals()", 

204 "__builtins__", 

205 "lambda.*exec", 

206 "lambda.*eval", 

207 "getattr.*__", 

208 } 

209 

210 @classmethod 

211 def scan_zip(cls, zip_path: str) -> dict[str, Any]: 

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

213 findings = [] 

214 score = 100 

215 files_scanned = 0 

216 total_size = 0 

217 

218 try: 

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

220 for info in zf.infolist(): 

221 if info.is_dir(): 

222 continue 

223 total_size += info.file_size 

224 name = info.filename.lower() 

225 

226 # Skip binary files 

227 if any( 

228 name.endswith(ext) 

229 for ext in (".pyc", ".so", ".dll", ".exe", ".png", ".jpg", ".ico") 

230 ): 

231 continue 

232 

233 try: 

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

235 files_scanned += 1 

236 except Exception: 

237 continue 

238 

239 content.split("\n") 

240 

241 # Check for dangerous imports 

242 for imp in cls.DANGEROUS_IMPORTS: 

243 if imp in content: 

244 findings.append( 

245 { 

246 "file": info.filename, 

247 "severity": "high", 

248 "rule": f"dangerous_import:{imp}", 

249 "line": content.find(imp), 

250 } 

251 ) 

252 score -= 20 

253 

254 # Check for shell injection 

255 for pat in cls.DANGEROUS_SHELL: 

256 if pat in content: 

257 findings.append( 

258 { 

259 "file": info.filename, 

260 "severity": "critical", 

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

262 } 

263 ) 

264 score -= 30 

265 

266 # Check for obfuscation 

267 for pat in cls.OBFUSCATION_SIGNALS: 

268 if re.search(pat, content): 

269 findings.append( 

270 { 

271 "file": info.filename, 

272 "severity": "medium", 

273 "rule": f"obfuscation:{pat}", 

274 } 

275 ) 

276 score -= 15 

277 

278 # Check for hardcoded secrets 

279 if re.search( 

280 r'(api_key|secret|password|token)\s*[:=]\s*["\'][a-zA-Z0-9_\-]{20,}', 

281 content, 

282 ): 

283 findings.append( 

284 {"file": info.filename, "severity": "high", "rule": "hardcoded_secret"} 

285 ) 

286 score -= 25 

287 except zipfile.BadZipFile: 

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

289 

290 return { 

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

292 "findings": findings, 

293 "files_scanned": files_scanned, 

294 "total_size": total_size, 

295 "risk_level": ( 

296 "low" 

297 if score >= 80 

298 else "medium" if score >= 50 else "high" if score >= 20 else "critical" 

299 ), 

300 } 

301 

302 @classmethod 

303 def validate_manifest(cls, manifest: dict) -> list[str]: 

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

305 errors = [] 

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

307 for field in required: 

308 if not manifest.get(field): 

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

310 

311 if "name" in manifest: 

312 name = manifest["name"] 

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

314 errors.append( 

315 f"Invalid skill name: {name}. Use alphanumeric, hyphens, underscores." 

316 ) 

317 

318 if "version" in manifest: 

319 version = manifest["version"] 

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

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

322 

323 return errors 

324 

325 

326# ── Auth Utilities ─────────────────────────── 

327 

328security_scheme = HTTPBearer(auto_error=False) 

329 

330 

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

332 payload = { 

333 "user_id": user_id, 

334 "username": username, 

335 "is_admin": is_admin, 

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

337 "iat": datetime.utcnow(), 

338 } 

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

340 

341 

342def decode_token(token: str) -> dict | None: 

343 try: 

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

345 except Exception: 

346 return None 

347 

348 

349async def get_current_user( 

350 credentials: HTTPAuthorizationCredentials | None = Depends(security_scheme), 

351): 

352 if not credentials: 

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

354 payload = decode_token(credentials.credentials) 

355 if not payload: 

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

357 db = get_db() 

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

359 db.close() 

360 if not user: 

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

362 return dict(user) 

363 

364 

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

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

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

368 return user 

369 

370 

371# ── FastAPI App ────────────────────────────── 

372 

373 

374def create_marketplace_app() -> FastAPI: 

375 init_db() 

376 

377 app = FastAPI( 

378 title="AgentOS Skill Marketplace", 

379 version="1.8.1", 

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

381 ) 

382 

383 app.add_middleware( 

384 CORSMiddleware, 

385 allow_origins=["*"], 

386 allow_methods=["*"], 

387 allow_headers=["*"], 

388 ) 

389 

390 if STATIC_DIR.exists(): 

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

392 

393 # ── Web UI ── 

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

395 async def web_ui(): 

396 html_path = STATIC_DIR / "platform.html" 

397 if html_path.exists(): 

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

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

400 

401 # ── Auth Endpoints ── 

402 

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

404 async def register( 

405 username: str = Form(...), 

406 email: str = Form(...), 

407 password: str = Form(...), 

408 display_name: str = Form(""), 

409 ): 

410 if len(password) < 6: 

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

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

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

414 

415 db = get_db() 

416 existing = db.execute( 

417 "SELECT id FROM users WHERE username=? OR email=?", (username, email) 

418 ).fetchone() 

419 if existing: 

420 db.close() 

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

422 

423 pw_hash = _hash_password(password) 

424 try: 

425 db.execute( 

426 "INSERT INTO users (username, email, password_hash, display_name) VALUES (?,?,?,?)", 

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

428 ) 

429 db.commit() 

430 user_id = db.lastrowid 

431 finally: 

432 db.close() 

433 

434 token = create_token(user_id, username, False) 

435 return { 

436 "token": token, 

437 "user": {"id": user_id, "username": username, "email": email, "is_admin": False}, 

438 } 

439 

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

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

442 db = get_db() 

443 user = db.execute( 

444 "SELECT * FROM users WHERE username = ? OR email = ?", (username, username) 

445 ).fetchone() 

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

447 db.close() 

448 raise HTTPException(401, "Invalid credentials") 

449 db.close() 

450 

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

452 return { 

453 "token": token, 

454 "user": { 

455 "id": user["id"], 

456 "username": user["username"], 

457 "email": user["email"], 

458 "display_name": user["display_name"], 

459 "is_admin": bool(user["is_admin"]), 

460 "github_username": user["github_username"], 

461 }, 

462 } 

463 

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

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

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

467 

468 # ── Skill Upload ── 

469 

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

471 async def upload_skill( 

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

473 name: str = Form(""), 

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

475 description: str = Form(""), 

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

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

478 user: dict = Depends(get_current_user), 

479 ): 

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

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

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

483 

484 # Save uploaded file 

485 ts = int(time.time()) 

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

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

488 file_path = UPLOAD_DIR / file_id 

489 content = await file.read() 

490 file_path.write_bytes(content) 

491 

492 # Validate zip 

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

494 file_path.unlink() 

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

496 

497 # Extract and validate manifest 

498 manifest = {} 

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

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

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

502 manifest = _parse_yaml_simple(manifest_text) 

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

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

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

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

507 

508 # Use form fields as fallback 

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

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

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

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

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

514 

515 # Use form tags if manifest has none 

516 if not skill_tags: 

517 try: 

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

519 except json.JSONDecodeError: 

520 skill_tags = [] 

521 

522 # Validate 

523 manifest_errors = SecurityScanner.validate_manifest( 

524 { 

525 "name": skill_name, 

526 "version": skill_version, 

527 "description": skill_desc, 

528 } 

529 ) 

530 if manifest_errors: 

531 file_path.unlink() 

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

533 

534 # Security scan 

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

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

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

538 auto_status = "rejected" 

539 file_path.unlink() 

540 raise HTTPException( 

541 400, 

542 f"Security scan failed (score: {security['score']}/100). " 

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

544 ) 

545 

546 # Compute hash 

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

548 

549 db = get_db() 

550 try: 

551 db.execute( 

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

553 format, entrypoint, manifest_json, file_path, file_size, file_hash, 

554 status, security_score, security_report) 

555 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", 

556 ( 

557 user["id"], 

558 skill_name, 

559 skill_version, 

560 skill_desc, 

561 skill_category, 

562 json.dumps(skill_tags), 

563 manifest.get("format", "agentos"), 

564 manifest.get("entrypoint", ""), 

565 json.dumps(manifest, ensure_ascii=False), 

566 str(file_path.absolute()), 

567 os.path.getsize(str(file_path)), 

568 file_hash, 

569 auto_status, 

570 security["score"], 

571 json.dumps(security, ensure_ascii=False), 

572 ), 

573 ) 

574 db.commit() 

575 skill_id = db.lastrowid 

576 except sqlite3.IntegrityError: 

577 file_path.unlink() 

578 db.close() 

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

580 db.close() 

581 

582 return { 

583 "id": skill_id, 

584 "name": skill_name, 

585 "version": skill_version, 

586 "status": auto_status, 

587 "security_score": security["score"], 

588 "risk_level": security["risk_level"], 

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

590 } 

591 

592 # ── Public Browse ── 

593 

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

595 async def list_skills( 

596 q: str = Query(""), 

597 category: str = Query(""), 

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

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

600 page: int = Query(1), 

601 limit: int = Query(30), 

602 ): 

603 db = get_db() 

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

605 params: list = [status] 

606 

607 if q: 

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

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

610 if category: 

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

612 params.append(category) 

613 

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

615 offset = (page - 1) * limit 

616 

617 skills = db.execute( 

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

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

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

621 ORDER BY {order} 

622 LIMIT ? OFFSET ?""", 

623 params + [limit, offset], 

624 ).fetchall() 

625 

626 total = db.execute( 

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

628 ).fetchone()[0] 

629 db.close() 

630 

631 return { 

632 "total": total, 

633 "page": page, 

634 "limit": limit, 

635 "skills": [ 

636 { 

637 "id": s["id"], 

638 "name": s["name"], 

639 "version": s["version"], 

640 "description": s["description"], 

641 "category": s["category"], 

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

643 "format": s["format"], 

644 "download_count": s["download_count"], 

645 "status": s["status"], 

646 "security_score": s["security_score"], 

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

648 "created_at": s["created_at"], 

649 } 

650 for s in skills 

651 ], 

652 } 

653 

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

655 async def get_skill_detail(skill_id: int): 

656 db = get_db() 

657 skill = db.execute( 

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

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

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

661 (skill_id,), 

662 ).fetchone() 

663 if not skill: 

664 db.close() 

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

666 

667 # Get review stats 

668 review_stats = db.execute( 

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

670 (skill_id,), 

671 ).fetchone() 

672 db.close() 

673 

674 return { 

675 "id": skill["id"], 

676 "name": skill["name"], 

677 "version": skill["version"], 

678 "description": skill["description"], 

679 "category": skill["category"], 

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

681 "format": skill["format"], 

682 "entrypoint": skill["entrypoint"], 

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

684 "download_count": skill["download_count"], 

685 "status": skill["status"], 

686 "security_score": skill["security_score"], 

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

688 "created_at": skill["created_at"], 

689 "reviews": { 

690 "count": review_stats["count"] or 0, 

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

692 }, 

693 } 

694 

695 # ── Download ── 

696 

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

698 async def download_skill(skill_id: int): 

699 db = get_db() 

700 skill = db.execute( 

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

702 ).fetchone() 

703 if not skill: 

704 db.close() 

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

706 

707 db.execute( 

708 "UPDATE skills SET download_count = download_count + 1 WHERE id = ?", (skill_id,) 

709 ) 

710 db.commit() 

711 db.close() 

712 

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

714 if not file_path.exists(): 

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

716 

717 return FileResponse( 

718 path=str(file_path), 

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

720 media_type="application/zip", 

721 ) 

722 

723 # ── Admin: Review Queue ── 

724 

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

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

727 db = get_db() 

728 skills = db.execute("""SELECT s.*, u.username as author_name 

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

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

731 ORDER BY s.created_at DESC""").fetchall() 

732 db.close() 

733 return { 

734 "count": len(skills), 

735 "skills": [ 

736 { 

737 "id": s["id"], 

738 "name": s["name"], 

739 "version": s["version"], 

740 "description": s["description"], 

741 "category": s["category"], 

742 "status": s["status"], 

743 "security_score": s["security_score"], 

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

745 "author": s["author_name"], 

746 "created_at": s["created_at"], 

747 } 

748 for s in skills 

749 ], 

750 } 

751 

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

753 async def review_skill( 

754 skill_id: int, 

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

756 comment: str = Form(""), 

757 user: dict = Depends(get_admin_user), 

758 ): 

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

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

761 

762 db = get_db() 

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

764 if not skill: 

765 db.close() 

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

767 

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

769 db.execute( 

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

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

772 ) 

773 db.commit() 

774 db.close() 

775 

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

777 

778 # ── Categories ── 

779 

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

781 async def list_categories(): 

782 db = get_db() 

783 cats = db.execute( 

784 "SELECT category, COUNT(*) as count FROM skills WHERE status='published' GROUP BY category ORDER BY count DESC" # noqa: E501 

785 ).fetchall() 

786 db.close() 

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

788 

789 # ── Developer Profile ── 

790 

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

792 async def developer_profile(username: str): 

793 db = get_db() 

794 user = db.execute( 

795 "SELECT id, username, display_name, avatar_url, github_username, created_at FROM users WHERE username = ?", 

796 (username,), 

797 ).fetchone() 

798 if not user: 

799 db.close() 

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

801 

802 skills = db.execute( 

803 "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", # noqa: E501 

804 (user["id"],), 

805 ).fetchall() 

806 db.close() 

807 

808 return { 

809 "developer": dict(user), 

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

811 "total_skills": len(skills), 

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

813 } 

814 

815 # ── My Skills ── 

816 

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

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

819 db = get_db() 

820 skills = db.execute( 

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

822 (user["id"],), 

823 ).fetchall() 

824 db.close() 

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

826 

827 # ── Health ── 

828 

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

830 async def health_check(): 

831 db = get_db() 

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

833 0 

834 ] 

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

836 db.close() 

837 return { 

838 "status": "healthy", 

839 "version": "1.8.1", 

840 "published_skills": skill_count, 

841 "registered_developers": user_count, 

842 } 

843 

844 return app 

845 

846 

847def _parse_yaml_simple(text: str) -> dict[str, Any]: 

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

849 result: dict[str, Any] = {} 

850 current_key = None 

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

852 stripped = line.strip() 

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

854 continue 

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

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

857 key = key.strip() 

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

859 if val: 

860 result[key] = val 

861 else: 

862 result[key] = [] 

863 current_key = key 

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

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

866 result[current_key].append(item) 

867 return result 

868 

869 

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

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

872 import uvicorn 

873 

874 app = create_marketplace_app() 

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

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

877 print(" Admin: admin / admin123") 

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

879 print() 

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