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
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 10: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 uuid
15from dataclasses import dataclass
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
29APP_VERSION = "1.7.1"
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
42class DesktopServer:
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]}"
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)
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 = []
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")
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
70 app = FastAPI(title="AgentOS Desktop", version=APP_VERSION, docs_url=None, redoc_url=None)
72 async def push_approval(data: dict) -> None:
73 await self._broadcast(data)
74 self._approval.set_push_callback(push_approval)
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)
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 }
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))
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))
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", "")))
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", "")))
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", "")))
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))
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 }
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}
162 # ── 可视化授权审批 API ──
163 @app.get("/api/approval/pending")
164 async def api_pending():
165 return {"tickets": self._approval.get_pending_tickets()}
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}
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 }
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 }
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}
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 }
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)}
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)}
290 # ── 静态文件 ──
291 if os.path.isdir(self._static_dir):
292 app.mount("/static", StaticFiles(directory=self._static_dir), name="static")
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>")
301 return app
303 async def _handle_ws_message(self, data: dict) -> dict:
304 msg_type = data.get("type", "")
305 payload = data.get("payload", {})
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)}
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)}
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)}
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 }
329 if msg_type == "get_pending_tickets":
330 return {"type": "pending_tickets", "data": {"tickets": self._approval.get_pending_tickets()}}
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}}
345 if msg_type == "ping":
346 return {"type": "pong"}
348 return {"type": "error", "data": {"message": f"未知消息类型: {msg_type}"}}
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
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 }
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")
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()