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

243 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 09:59 +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 json 

15import uuid 

16from dataclasses import dataclass 

17from typing import Optional 

18 

19from agentos.system.permissions import ( 

20 SystemPermissionManager, 

21 PermissionTier, 

22 SAFE_PERMISSIONS, 

23 DEV_PERMISSIONS, 

24) 

25from agentos.system.file_ops import FileOperator, FileOpResult 

26from agentos.system.shell_exec import ShellExecutor 

27from agentos.system.approval import ApprovalEngine 

28from agentos.enterprise.api_keys import ( 

29 APIKeyManager, KeyScope, KeyCreateRequest, APIKey, 

30) 

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

32 

33APP_VERSION = "1.7.1" 

34 

35 

36@dataclass 

37class DesktopConfig: 

38 host: str = "0.0.0.0" 

39 port: int = 19999 

40 auto_open: bool = True 

41 permission_mode: str = "dev" 

42 static_dir: str = "" 

43 debug: bool = False 

44 

45 

46class DesktopServer: 

47 

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

49 self._config = config or DesktopConfig() 

50 self._pm = SystemPermissionManager() 

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

52 

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

54 self._pm.set_safe_mode(self._sid) 

55 else: 

56 self._pm.set_dev_mode(self._sid) 

57 

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

59 self._key_mgr = APIKeyManager() 

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

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

62 self._ws_clients: list = [] 

63 

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

65 self._static_dir = self._config.static_dir 

66 else: 

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

68 

69 def build_app(self): 

70 from fastapi import FastAPI, WebSocket, WebSocketDisconnect 

71 from fastapi.staticfiles import StaticFiles 

72 from fastapi.responses import FileResponse, HTMLResponse 

73 

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

75 

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

77 await self._broadcast(data) 

78 self._approval.set_push_callback(push_approval) 

79 

80 # ── WebSocket ── 

81 @app.websocket("/ws") 

82 async def ws_endpoint(ws: WebSocket): 

83 await ws.accept() 

84 self._ws_clients.append(ws) 

85 try: 

86 await ws.send_json({ 

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 while True: 

94 data = await ws.receive_json() 

95 resp = await self._handle_ws_message(data) 

96 await ws.send_json(resp) 

97 except WebSocketDisconnect: 

98 pass 

99 finally: 

100 self._ws_clients.remove(ws) 

101 

102 # ── REST ── 

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

104 async def api_status(): 

105 return { 

106 "version": APP_VERSION, 

107 "session_id": self._sid, 

108 "permission_mode": self._config.permission_mode, 

109 "pid": os.getpid(), 

110 "work_dir": os.getcwd(), 

111 } 

112 

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

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

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

116 

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

118 async def api_read_file(path: str): 

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

120 

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

122 async def api_write_file(data: dict): 

123 return self._file_result_to_dict(self.file_op.write(data.get("path", ""), data.get("content", ""))) 

124 

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

126 async def api_mkdir(data: dict): 

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

128 

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

130 async def api_delete(data: dict): 

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

132 

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

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

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

136 

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

138 async def api_shell(data: dict): 

139 tier_map = { 

140 "readonly": PermissionTier.SHELL_READONLY, 

141 "standard": PermissionTier.SHELL_STANDARD, 

142 "full": PermissionTier.SHELL_FULL, 

143 } 

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

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

146 return { 

147 "success": result.success, "command": result.command, 

148 "stdout": result.stdout, "stderr": result.stderr, 

149 "exit_code": result.exit_code, "duration_ms": result.duration_ms, 

150 "timeout": result.timeout, "error": result.error, 

151 } 

152 

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

154 async def api_set_permission(data: dict): 

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

156 if mode == "dev": 

157 self._pm.set_dev_mode(self._sid) 

158 elif mode == "full": 

159 self._pm.set_full_mode(self._sid) 

160 else: 

161 self._pm.set_safe_mode(self._sid) 

162 self._config.permission_mode = mode 

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

164 return {"mode": mode} 

165 

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

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

168 async def api_pending(): 

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

170 

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

172 async def api_resolve(data: dict): 

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

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

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

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

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

178 if ok: 

179 await self._broadcast({ 

180 "type": "approval_resolved", 

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

182 }) 

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

184 

185 # ── API Key 管理 API ── 

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

187 async def api_list_keys(): 

188 keys = self._key_mgr.list_keys() 

189 return { 

190 "keys": [ 

191 { 

192 "key_id": k.key_id, 

193 "key_prefix": k.key_prefix, 

194 "name": k.name, 

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

196 "created_at": k.created_at, 

197 "expires_at": k.expires_at, 

198 "last_used_at": k.last_used_at, 

199 "usage_count": k.usage_count, 

200 "revoked": k.revoked, 

201 } 

202 for k in keys 

203 ], 

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

205 } 

206 

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

208 async def api_create_key(data: dict): 

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

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

211 expires_in_days = data.get("expires_in_days") 

212 scopes = [] 

213 for s in scope_names: 

214 try: 

215 scopes.append(KeyScope(s)) 

216 except ValueError: 

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

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

219 result = self._key_mgr.create_key(req) 

220 return { 

221 "key_id": result.key_id, 

222 "plaintext_key": result.plaintext_key, 

223 "key_prefix": result.key_prefix, 

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

225 "expires_at": result.expires_at, 

226 } 

227 

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

229 async def api_revoke_key(key_id: str): 

230 ok = self._key_mgr.revoke_key(key_id) 

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

232 

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

234 async def api_rotate_key(key_id: str): 

235 result = self._key_mgr.rotate_key(key_id) 

236 if not result: 

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

238 return { 

239 "key_id": result.key_id, 

240 "plaintext_key": result.plaintext_key, 

241 "key_prefix": result.key_prefix, 

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

243 "expires_at": result.expires_at, 

244 } 

245 

246 # ── 配置面板 API ── 

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

248 async def api_get_config(): 

249 import yaml 

250 config = {} 

251 if CONFIG_FILE.exists(): 

252 with open(CONFIG_FILE) as f: 

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

254 env_vars = {} 

255 if ENV_FILE.exists(): 

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

257 line = line.strip() 

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

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

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

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

262 

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

264 async def api_set_config(data: dict): 

265 import yaml 

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

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

268 section = data.get("section") 

269 if section: 

270 if not CONFIG_FILE.exists(): 

271 full = {} 

272 else: 

273 with open(CONFIG_FILE) as f: 

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

275 full[section] = config 

276 config = full 

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

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

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

280 if env_vars: 

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

282 existing = set() 

283 for line in lines: 

284 line = line.strip() 

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

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

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

288 if k in existing: 

289 continue 

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

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

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

293 

294 # ── 静态文件 ── 

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

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

297 

298 @app.get("/") 

299 async def index(): 

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

301 if os.path.isfile(idx): 

302 return FileResponse(idx) 

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

304 

305 return app 

306 

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

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

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

310 

311 if msg_type == "list_dir": 

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

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

314 

315 if msg_type == "read_file": 

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

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

318 

319 if msg_type == "write_file": 

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

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

322 

323 if msg_type == "shell": 

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

325 return { 

326 "type": "shell_result", 

327 "data": { 

328 "success": r.success, "stdout": r.stdout, "stderr": r.stderr, 

329 "exit_code": r.exit_code, "duration_ms": r.duration_ms, "error": r.error, 

330 }, 

331 } 

332 

333 if msg_type == "get_pending_tickets": 

334 return {"type": "pending_tickets", "data": {"tickets": self._approval.get_pending_tickets()}} 

335 

336 if msg_type == "resolve_ticket": 

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

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

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

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

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

342 if ok: 

343 await self._broadcast({ 

344 "type": "approval_resolved", 

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

346 }) 

347 return {"type": "resolve_ticket_result", "data": {"success": ok, "ticket_id": ticket_id, "status": status}} 

348 

349 if msg_type == "ping": 

350 return {"type": "pong"} 

351 

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

353 

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

355 for ws in self._ws_clients: 

356 try: 

357 await ws.send_json(message) 

358 except Exception: 

359 pass 

360 

361 @staticmethod 

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

363 return { 

364 "success": result.success, "action": result.action, 

365 "path": result.path, "content": result.content, "error": result.error, 

366 "listing": [ 

367 {"name": e.name, "path": e.path, "is_dir": e.is_dir, 

368 "size_bytes": e.size_bytes, "modified_at": e.modified_at, "mime_type": e.mime_type} 

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

370 ], 

371 } 

372 

373 def serve(self) -> None: 

374 import uvicorn 

375 app = self.build_app() 

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

377 print(f" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 

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

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

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

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

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

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

384 print(f" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") 

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

386 

387 

388def launch_desktop(host: str = "0.0.0.0", port: int = 19999, 

389 mode: str = "dev", auto_open: bool = True) -> None: 

390 DesktopServer(DesktopConfig(host=host, port=port, permission_mode=mode, auto_open=auto_open)).serve()