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

241 statements  

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

15from dataclasses import dataclass 

16 

17from agentos.system.permissions import ( 

18 SystemPermissionManager, 

19 PermissionTier, 

20) 

21from agentos.system.file_ops import FileOperator, FileOpResult 

22from agentos.system.shell_exec import ShellExecutor 

23from agentos.system.approval import ApprovalEngine 

24from agentos.enterprise.api_keys import ( 

25 APIKeyManager, KeyScope, KeyCreateRequest, 

26) 

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

28 

29APP_VERSION = "1.7.1" 

30 

31 

32@dataclass 

33class DesktopConfig: 

34 host: str = "0.0.0.0" 

35 port: int = 19999 

36 auto_open: bool = True 

37 permission_mode: str = "dev" 

38 static_dir: str = "" 

39 debug: bool = False 

40 

41 

42class DesktopServer: 

43 

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

45 self._config = config or DesktopConfig() 

46 self._pm = SystemPermissionManager() 

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

48 

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

50 self._pm.set_safe_mode(self._sid) 

51 else: 

52 self._pm.set_dev_mode(self._sid) 

53 

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

55 self._key_mgr = APIKeyManager() 

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

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

58 self._ws_clients: list = [] 

59 

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

61 self._static_dir = self._config.static_dir 

62 else: 

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

64 

65 def build_app(self): 

66 from fastapi import FastAPI, WebSocket, WebSocketDisconnect 

67 from fastapi.staticfiles import StaticFiles 

68 from fastapi.responses import FileResponse, HTMLResponse 

69 

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

71 

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

73 await self._broadcast(data) 

74 self._approval.set_push_callback(push_approval) 

75 

76 # ── WebSocket ── 

77 @app.websocket("/ws") 

78 async def ws_endpoint(ws: WebSocket): 

79 await ws.accept() 

80 self._ws_clients.append(ws) 

81 try: 

82 await ws.send_json({ 

83 "type": "connected", 

84 "session_id": self._sid, 

85 "permission_mode": self._config.permission_mode, 

86 "work_dir": os.getcwd(), 

87 "version": APP_VERSION, 

88 }) 

89 while True: 

90 data = await ws.receive_json() 

91 resp = await self._handle_ws_message(data) 

92 await ws.send_json(resp) 

93 except WebSocketDisconnect: 

94 pass 

95 finally: 

96 self._ws_clients.remove(ws) 

97 

98 # ── REST ── 

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

100 async def api_status(): 

101 return { 

102 "version": APP_VERSION, 

103 "session_id": self._sid, 

104 "permission_mode": self._config.permission_mode, 

105 "pid": os.getpid(), 

106 "work_dir": os.getcwd(), 

107 } 

108 

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

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

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

112 

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

114 async def api_read_file(path: str): 

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

116 

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

118 async def api_write_file(data: dict): 

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

120 

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

122 async def api_mkdir(data: dict): 

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

124 

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

126 async def api_delete(data: dict): 

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

128 

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

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

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

132 

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

134 async def api_shell(data: dict): 

135 tier_map = { 

136 "readonly": PermissionTier.SHELL_READONLY, 

137 "standard": PermissionTier.SHELL_STANDARD, 

138 "full": PermissionTier.SHELL_FULL, 

139 } 

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

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

142 return { 

143 "success": result.success, "command": result.command, 

144 "stdout": result.stdout, "stderr": result.stderr, 

145 "exit_code": result.exit_code, "duration_ms": result.duration_ms, 

146 "timeout": result.timeout, "error": result.error, 

147 } 

148 

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

150 async def api_set_permission(data: dict): 

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

152 if mode == "dev": 

153 self._pm.set_dev_mode(self._sid) 

154 elif mode == "full": 

155 self._pm.set_full_mode(self._sid) 

156 else: 

157 self._pm.set_safe_mode(self._sid) 

158 self._config.permission_mode = mode 

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

160 return {"mode": mode} 

161 

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

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

164 async def api_pending(): 

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

166 

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

168 async def api_resolve(data: dict): 

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

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

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

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

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

174 if ok: 

175 await self._broadcast({ 

176 "type": "approval_resolved", 

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

178 }) 

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

180 

181 # ── API Key 管理 API ── 

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

183 async def api_list_keys(): 

184 keys = self._key_mgr.list_keys() 

185 return { 

186 "keys": [ 

187 { 

188 "key_id": k.key_id, 

189 "key_prefix": k.key_prefix, 

190 "name": k.name, 

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

192 "created_at": k.created_at, 

193 "expires_at": k.expires_at, 

194 "last_used_at": k.last_used_at, 

195 "usage_count": k.usage_count, 

196 "revoked": k.revoked, 

197 } 

198 for k in keys 

199 ], 

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

201 } 

202 

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

204 async def api_create_key(data: dict): 

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

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

207 expires_in_days = data.get("expires_in_days") 

208 scopes = [] 

209 for s in scope_names: 

210 try: 

211 scopes.append(KeyScope(s)) 

212 except ValueError: 

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

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

215 result = self._key_mgr.create_key(req) 

216 return { 

217 "key_id": result.key_id, 

218 "plaintext_key": result.plaintext_key, 

219 "key_prefix": result.key_prefix, 

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

221 "expires_at": result.expires_at, 

222 } 

223 

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

225 async def api_revoke_key(key_id: str): 

226 ok = self._key_mgr.revoke_key(key_id) 

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

228 

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

230 async def api_rotate_key(key_id: str): 

231 result = self._key_mgr.rotate_key(key_id) 

232 if not result: 

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

234 return { 

235 "key_id": result.key_id, 

236 "plaintext_key": result.plaintext_key, 

237 "key_prefix": result.key_prefix, 

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

239 "expires_at": result.expires_at, 

240 } 

241 

242 # ── 配置面板 API ── 

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

244 async def api_get_config(): 

245 import yaml 

246 config = {} 

247 if CONFIG_FILE.exists(): 

248 with open(CONFIG_FILE) as f: 

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

250 env_vars = {} 

251 if ENV_FILE.exists(): 

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

253 line = line.strip() 

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

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

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

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

258 

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

260 async def api_set_config(data: dict): 

261 import yaml 

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

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

264 section = data.get("section") 

265 if section: 

266 if not CONFIG_FILE.exists(): 

267 full = {} 

268 else: 

269 with open(CONFIG_FILE) as f: 

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

271 full[section] = config 

272 config = full 

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

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

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

276 if env_vars: 

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

278 existing = set() 

279 for line in lines: 

280 line = line.strip() 

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

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

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

284 if k in existing: 

285 continue 

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

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

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

289 

290 # ── 静态文件 ── 

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

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

293 

294 @app.get("/") 

295 async def index(): 

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

297 if os.path.isfile(idx): 

298 return FileResponse(idx) 

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

300 

301 return app 

302 

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

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

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

306 

307 if msg_type == "list_dir": 

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

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

310 

311 if msg_type == "read_file": 

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

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

314 

315 if msg_type == "write_file": 

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

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

318 

319 if msg_type == "shell": 

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

321 return { 

322 "type": "shell_result", 

323 "data": { 

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

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

326 }, 

327 } 

328 

329 if msg_type == "get_pending_tickets": 

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

331 

332 if msg_type == "resolve_ticket": 

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

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

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

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

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

338 if ok: 

339 await self._broadcast({ 

340 "type": "approval_resolved", 

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

342 }) 

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

344 

345 if msg_type == "ping": 

346 return {"type": "pong"} 

347 

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

349 

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

351 for ws in self._ws_clients: 

352 try: 

353 await ws.send_json(message) 

354 except Exception: 

355 pass 

356 

357 @staticmethod 

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

359 return { 

360 "success": result.success, "action": result.action, 

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

362 "listing": [ 

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

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

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

366 ], 

367 } 

368 

369 def serve(self) -> None: 

370 import uvicorn 

371 app = self.build_app() 

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

373 print(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 

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

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

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

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

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

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

380 print(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") 

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

382 

383 

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

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

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