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
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-02 09:59 +0800
1"""
2Desktop Server — AgentOS 桌面客户端后端 v1.7.1。
4功能:
5- FastAPI HTTP API(文件浏览、Shell 执行、Agent 对话、授权审批)
6- WebSocket 实时推送(含可视化授权卡片)
7- 静态文件服务(前端 SPA)
8- System 模块集成(权限分层 + 可视化授权审批引擎)
9"""
11from __future__ import annotations
13import os
14import json
15import uuid
16from dataclasses import dataclass
17from typing import Optional
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
33APP_VERSION = "1.7.1"
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
46class DesktopServer:
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]}"
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)
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 = []
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")
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
74 app = FastAPI(title="AgentOS Desktop", version=APP_VERSION, docs_url=None, redoc_url=None)
76 async def push_approval(data: dict) -> None:
77 await self._broadcast(data)
78 self._approval.set_push_callback(push_approval)
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)
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 }
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))
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))
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", "")))
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", "")))
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", "")))
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))
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 }
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}
166 # ── 可视化授权审批 API ──
167 @app.get("/api/approval/pending")
168 async def api_pending():
169 return {"tickets": self._approval.get_pending_tickets()}
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}
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 }
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 }
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}
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 }
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)}
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)}
294 # ── 静态文件 ──
295 if os.path.isdir(self._static_dir):
296 app.mount("/static", StaticFiles(directory=self._static_dir), name="static")
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>")
305 return app
307 async def _handle_ws_message(self, data: dict) -> dict:
308 msg_type = data.get("type", "")
309 payload = data.get("payload", {})
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)}
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)}
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)}
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 }
333 if msg_type == "get_pending_tickets":
334 return {"type": "pending_tickets", "data": {"tickets": self._approval.get_pending_tickets()}}
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}}
349 if msg_type == "ping":
350 return {"type": "pong"}
352 return {"type": "error", "data": {"message": f"未知消息类型: {msg_type}"}}
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
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 }
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")
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()