Coverage for agentos/desktop/server.py: 12%

241 statements  

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

1""" 

2Desktop Server — AgentOS 桌面客户端后端 v1.7.1。 

3 

4功能: 

5- FastAPI HTTP API(文件浏览、Shell 执行、Agent 对话、授权审批) 

6- WebSocket 实时推送(含可视化授权卡片) 

7- 静态文件服务(前端 SPA) 

8- System 模块集成(权限分层 + 可视化授权审批引擎) 

9""" 

10 

11from __future__ import annotations 

12 

13import os 

14import uuid 

15from dataclasses import dataclass 

16 

17from agentos.cli.config_panel import CONFIG_DIR, CONFIG_FILE, ENV_FILE 

18from agentos.enterprise.api_keys import ( 

19 APIKeyManager, 

20 KeyCreateRequest, 

21 KeyScope, 

22) 

23from agentos.system.approval import ApprovalEngine 

24from agentos.system.file_ops import FileOperator, FileOpResult 

25from agentos.system.permissions import ( 

26 PermissionTier, 

27 SystemPermissionManager, 

28) 

29from agentos.system.shell_exec import ShellExecutor 

30 

31APP_VERSION = "1.7.1" 

32 

33 

34@dataclass 

35class DesktopConfig: 

36 host: str = "0.0.0.0" 

37 port: int = 19999 

38 auto_open: bool = True 

39 permission_mode: str = "dev" 

40 static_dir: str = "" 

41 debug: bool = False 

42 

43 

44class DesktopServer: 

45 

46 def __init__(self, config: DesktopConfig | None = None): 

47 self._config = config or DesktopConfig() 

48 self._pm = SystemPermissionManager() 

49 self._sid = f"desktop-{uuid.uuid4().hex[:8]}" 

50 

51 if self._config.permission_mode == "safe": 

52 self._pm.set_safe_mode(self._sid) 

53 else: 

54 self._pm.set_dev_mode(self._sid) 

55 

56 self._approval = ApprovalEngine(self._pm, self._sid) 

57 self._key_mgr = APIKeyManager() 

58 self.file_op = FileOperator(self._pm, self._sid) 

59 self.shell_exec = ShellExecutor(self._pm, self._sid) 

60 self._ws_clients: list = [] 

61 

62 if self._config.static_dir and os.path.isdir(self._config.static_dir): 

63 self._static_dir = self._config.static_dir 

64 else: 

65 self._static_dir = os.path.join(os.path.dirname(__file__), "static") 

66 

67 def build_app(self): 

68 from fastapi import FastAPI, WebSocket, WebSocketDisconnect 

69 from fastapi.responses import FileResponse, HTMLResponse 

70 from fastapi.staticfiles import StaticFiles 

71 

72 app = FastAPI(title="AgentOS Desktop", version=APP_VERSION, docs_url=None, redoc_url=None) 

73 

74 async def push_approval(data: dict) -> None: 

75 await self._broadcast(data) 

76 

77 self._approval.set_push_callback(push_approval) 

78 

79 # ── WebSocket ── 

80 @app.websocket("/ws") 

81 async def ws_endpoint(ws: WebSocket): 

82 await ws.accept() 

83 self._ws_clients.append(ws) 

84 try: 

85 await ws.send_json( 

86 { 

87 "type": "connected", 

88 "session_id": self._sid, 

89 "permission_mode": self._config.permission_mode, 

90 "work_dir": os.getcwd(), 

91 "version": APP_VERSION, 

92 } 

93 ) 

94 while True: 

95 data = await ws.receive_json() 

96 resp = await self._handle_ws_message(data) 

97 await ws.send_json(resp) 

98 except WebSocketDisconnect: 

99 pass 

100 finally: 

101 self._ws_clients.remove(ws) 

102 

103 # ── REST ── 

104 @app.get("/api/status") 

105 async def api_status(): 

106 return { 

107 "version": APP_VERSION, 

108 "session_id": self._sid, 

109 "permission_mode": self._config.permission_mode, 

110 "pid": os.getpid(), 

111 "work_dir": os.getcwd(), 

112 } 

113 

114 @app.get("/api/fs/list") 

115 async def api_list_dir(path: str = "/home"): 

116 return self._file_result_to_dict(self.file_op.list_dir(path, show_hidden=False)) 

117 

118 @app.get("/api/fs/read") 

119 async def api_read_file(path: str): 

120 return self._file_result_to_dict(self.file_op.read(path)) 

121 

122 @app.post("/api/fs/write") 

123 async def api_write_file(data: dict): 

124 return self._file_result_to_dict( 

125 self.file_op.write(data.get("path", ""), data.get("content", "")) 

126 ) 

127 

128 @app.post("/api/fs/mkdir") 

129 async def api_mkdir(data: dict): 

130 return self._file_result_to_dict(self.file_op.mkdir(data.get("path", ""))) 

131 

132 @app.post("/api/fs/delete") 

133 async def api_delete(data: dict): 

134 return self._file_result_to_dict(self.file_op.delete(data.get("path", ""))) 

135 

136 @app.get("/api/fs/search") 

137 async def api_search(path: str, pattern: str = "*"): 

138 return self._file_result_to_dict(self.file_op.search(path, pattern)) 

139 

140 @app.post("/api/shell") 

141 async def api_shell(data: dict): 

142 tier_map = { 

143 "readonly": PermissionTier.SHELL_READONLY, 

144 "standard": PermissionTier.SHELL_STANDARD, 

145 "full": PermissionTier.SHELL_FULL, 

146 } 

147 tier = tier_map.get(data.get("tier", "standard"), PermissionTier.SHELL_STANDARD) 

148 result = self.shell_exec.execute_checked(data.get("command", ""), tier) 

149 return { 

150 "success": result.success, 

151 "command": result.command, 

152 "stdout": result.stdout, 

153 "stderr": result.stderr, 

154 "exit_code": result.exit_code, 

155 "duration_ms": result.duration_ms, 

156 "timeout": result.timeout, 

157 "error": result.error, 

158 } 

159 

160 @app.post("/api/permission/mode") 

161 async def api_set_permission(data: dict): 

162 mode = data.get("mode", "safe") 

163 if mode == "dev": 

164 self._pm.set_dev_mode(self._sid) 

165 elif mode == "full": 

166 self._pm.set_full_mode(self._sid) 

167 else: 

168 self._pm.set_safe_mode(self._sid) 

169 self._config.permission_mode = mode 

170 await self._broadcast({"type": "permission_changed", "mode": mode}) 

171 return {"mode": mode} 

172 

173 # ── 可视化授权审批 API ── 

174 @app.get("/api/approval/pending") 

175 async def api_pending(): 

176 return {"tickets": self._approval.get_pending_tickets()} 

177 

178 @app.post("/api/approval/resolve") 

179 async def api_resolve(data: dict): 

180 ticket_id = data.get("ticket_id", "") 

181 approved = data.get("approved", False) 

182 remember = data.get("remember", False) 

183 ok = self._approval.resolve(ticket_id, approved, remember) 

184 status = "approved" if approved else ("denied_remember" if remember else "denied") 

185 if ok: 

186 await self._broadcast( 

187 { 

188 "type": "approval_resolved", 

189 "data": {"ticket_id": ticket_id, "status": status}, 

190 } 

191 ) 

192 return {"success": ok, "ticket_id": ticket_id, "status": status} 

193 

194 # ── API Key 管理 API ── 

195 @app.get("/api/apikeys") 

196 async def api_list_keys(): 

197 keys = self._key_mgr.list_keys() 

198 return { 

199 "keys": [ 

200 { 

201 "key_id": k.key_id, 

202 "key_prefix": k.key_prefix, 

203 "name": k.name, 

204 "scopes": [s.value for s in k.scopes], 

205 "created_at": k.created_at, 

206 "expires_at": k.expires_at, 

207 "last_used_at": k.last_used_at, 

208 "usage_count": k.usage_count, 

209 "revoked": k.revoked, 

210 } 

211 for k in keys 

212 ], 

213 "stats": self._key_mgr.stats(), 

214 } 

215 

216 @app.post("/api/apikeys") 

217 async def api_create_key(data: dict): 

218 name = data.get("name", "Unnamed") 

219 scope_names = data.get("scopes", ["read", "write"]) 

220 expires_in_days = data.get("expires_in_days") 

221 scopes = [] 

222 for s in scope_names: 

223 try: 

224 scopes.append(KeyScope(s)) 

225 except ValueError: 

226 return {"error": f"Invalid scope: {s}"} 

227 req = KeyCreateRequest(name=name, scopes=scopes, expires_in_days=expires_in_days) 

228 result = self._key_mgr.create_key(req) 

229 return { 

230 "key_id": result.key_id, 

231 "plaintext_key": result.plaintext_key, 

232 "key_prefix": result.key_prefix, 

233 "scopes": [s.value for s in result.scopes], 

234 "expires_at": result.expires_at, 

235 } 

236 

237 @app.delete("/api/apikeys/{key_id}") 

238 async def api_revoke_key(key_id: str): 

239 ok = self._key_mgr.revoke_key(key_id) 

240 return {"success": ok, "key_id": key_id} 

241 

242 @app.post("/api/apikeys/{key_id}/rotate") 

243 async def api_rotate_key(key_id: str): 

244 result = self._key_mgr.rotate_key(key_id) 

245 if not result: 

246 return {"error": f"Key not found or already revoked: {key_id}"} 

247 return { 

248 "key_id": result.key_id, 

249 "plaintext_key": result.plaintext_key, 

250 "key_prefix": result.key_prefix, 

251 "scopes": [s.value for s in result.scopes], 

252 "expires_at": result.expires_at, 

253 } 

254 

255 # ── 配置面板 API ── 

256 @app.get("/api/config") 

257 async def api_get_config(): 

258 import yaml 

259 

260 config = {} 

261 if CONFIG_FILE.exists(): 

262 with open(CONFIG_FILE) as f: 

263 config = yaml.safe_load(f) or {} 

264 env_vars = {} 

265 if ENV_FILE.exists(): 

266 for line in ENV_FILE.read_text().strip().split("\n"): 

267 line = line.strip() 

268 if line and "=" in line and not line.startswith("#"): 

269 k, v = line.split("=", 1) 

270 env_vars[k.strip()] = v.strip() 

271 return {"config": config, "env_vars": env_vars, "config_path": str(CONFIG_FILE)} 

272 

273 @app.post("/api/config") 

274 async def api_set_config(data: dict): 

275 import yaml 

276 

277 config = data.get("config", {}) 

278 env_vars = data.get("env_vars", {}) 

279 section = data.get("section") 

280 if section: 

281 if not CONFIG_FILE.exists(): 

282 full = {} 

283 else: 

284 with open(CONFIG_FILE) as f: 

285 full = yaml.safe_load(f) or {} 

286 full[section] = config 

287 config = full 

288 CONFIG_DIR.mkdir(parents=True, exist_ok=True) 

289 with open(CONFIG_FILE, "w") as f: 

290 yaml.dump(config, f, allow_unicode=True, default_flow_style=False, sort_keys=False) 

291 if env_vars: 

292 lines = ENV_FILE.read_text().strip().split("\n") if ENV_FILE.exists() else [] 

293 existing = set() 

294 for line in lines: 

295 line = line.strip() 

296 if line and "=" in line and not line.startswith("#"): 

297 existing.add(line.split("=", 1)[0].strip()) 

298 for k, v in env_vars.items(): 

299 if k in existing: 

300 continue 

301 lines.append(f"{k}={v}") 

302 ENV_FILE.write_text("\n".join(lines) + "\n") 

303 return {"success": True, "config_path": str(CONFIG_FILE)} 

304 

305 # ── 静态文件 ── 

306 if os.path.isdir(self._static_dir): 

307 app.mount("/static", StaticFiles(directory=self._static_dir), name="static") 

308 

309 @app.get("/") 

310 async def index(): 

311 idx = os.path.join(self._static_dir, "index.html") 

312 if os.path.isfile(idx): 

313 return FileResponse(idx) 

314 return HTMLResponse("<h1>AgentOS Desktop</h1><p>Static files not found.</p>") 

315 

316 return app 

317 

318 async def _handle_ws_message(self, data: dict) -> dict: 

319 msg_type = data.get("type", "") 

320 payload = data.get("payload", {}) 

321 

322 if msg_type == "list_dir": 

323 r = self.file_op.list_dir(payload.get("path", "/"), payload.get("show_hidden", False)) 

324 return {"type": "list_dir_result", "data": self._file_result_to_dict(r)} 

325 

326 if msg_type == "read_file": 

327 r = self.file_op.read(payload.get("path", "")) 

328 return {"type": "read_file_result", "data": self._file_result_to_dict(r)} 

329 

330 if msg_type == "write_file": 

331 r = self.file_op.write(payload.get("path", ""), payload.get("content", "")) 

332 return {"type": "write_file_result", "data": self._file_result_to_dict(r)} 

333 

334 if msg_type == "shell": 

335 r = self.shell_exec.execute(payload.get("command", "")) 

336 return { 

337 "type": "shell_result", 

338 "data": { 

339 "success": r.success, 

340 "stdout": r.stdout, 

341 "stderr": r.stderr, 

342 "exit_code": r.exit_code, 

343 "duration_ms": r.duration_ms, 

344 "error": r.error, 

345 }, 

346 } 

347 

348 if msg_type == "get_pending_tickets": 

349 return { 

350 "type": "pending_tickets", 

351 "data": {"tickets": self._approval.get_pending_tickets()}, 

352 } 

353 

354 if msg_type == "resolve_ticket": 

355 ticket_id = payload.get("ticket_id", "") 

356 approved = payload.get("approved", False) 

357 remember = payload.get("remember", False) 

358 ok = self._approval.resolve(ticket_id, approved, remember) 

359 status = "approved" if approved else ("denied_remember" if remember else "denied") 

360 if ok: 

361 await self._broadcast( 

362 { 

363 "type": "approval_resolved", 

364 "data": {"ticket_id": ticket_id, "status": status}, 

365 } 

366 ) 

367 return { 

368 "type": "resolve_ticket_result", 

369 "data": {"success": ok, "ticket_id": ticket_id, "status": status}, 

370 } 

371 

372 if msg_type == "ping": 

373 return {"type": "pong"} 

374 

375 return {"type": "error", "data": {"message": f"未知消息类型: {msg_type}"}} 

376 

377 async def _broadcast(self, message: dict) -> None: 

378 for ws in self._ws_clients: 

379 try: 

380 await ws.send_json(message) 

381 except Exception: 

382 pass 

383 

384 @staticmethod 

385 def _file_result_to_dict(result: FileOpResult) -> dict: 

386 return { 

387 "success": result.success, 

388 "action": result.action, 

389 "path": result.path, 

390 "content": result.content, 

391 "error": result.error, 

392 "listing": [ 

393 { 

394 "name": e.name, 

395 "path": e.path, 

396 "is_dir": e.is_dir, 

397 "size_bytes": e.size_bytes, 

398 "modified_at": e.modified_at, 

399 "mime_type": e.mime_type, 

400 } 

401 for e in (result.listing or []) 

402 ], 

403 } 

404 

405 def serve(self) -> None: 

406 import uvicorn 

407 

408 app = self.build_app() 

409 print(f"\n AgentOS Desktop v{APP_VERSION}") 

410 print(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 

411 print(f" 地址: http://{self._config.host}:{self._config.port}") 

412 print(f" 模式: {self._config.permission_mode}") 

413 print(f" 工作区: {os.getcwd()}") 

414 print(f" 会话: {self._sid}") 

415 print(" 授权引擎: 可视化审批(Agent主动申请 → 用户点击允许/拒绝)") 

416 print(" 桌面壳: agentos desktop-shell(原生窗口包裹)") 

417 print(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") 

418 uvicorn.run(app, host=self._config.host, port=self._config.port, log_level="warning") 

419 

420 

421def launch_desktop( 

422 host: str = "0.0.0.0", port: int = 19999, mode: str = "dev", auto_open: bool = True 

423) -> None: 

424 DesktopServer( 

425 DesktopConfig(host=host, port=port, permission_mode=mode, auto_open=auto_open) 

426 ).serve()