Coverage for agentos/desktop/server.py: 12%
241 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 09:19 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 09:19 +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.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
31APP_VERSION = "1.7.1"
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
44class DesktopServer:
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]}"
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)
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 = []
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")
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
72 app = FastAPI(title="AgentOS Desktop", version=APP_VERSION, docs_url=None, redoc_url=None)
74 async def push_approval(data: dict) -> None:
75 await self._broadcast(data)
77 self._approval.set_push_callback(push_approval)
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)
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 }
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))
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))
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 )
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", "")))
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", "")))
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))
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 }
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}
173 # ── 可视化授权审批 API ──
174 @app.get("/api/approval/pending")
175 async def api_pending():
176 return {"tickets": self._approval.get_pending_tickets()}
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}
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 }
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 }
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}
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 }
255 # ── 配置面板 API ──
256 @app.get("/api/config")
257 async def api_get_config():
258 import yaml
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)}
273 @app.post("/api/config")
274 async def api_set_config(data: dict):
275 import yaml
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)}
305 # ── 静态文件 ──
306 if os.path.isdir(self._static_dir):
307 app.mount("/static", StaticFiles(directory=self._static_dir), name="static")
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>")
316 return app
318 async def _handle_ws_message(self, data: dict) -> dict:
319 msg_type = data.get("type", "")
320 payload = data.get("payload", {})
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)}
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)}
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)}
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 }
348 if msg_type == "get_pending_tickets":
349 return {
350 "type": "pending_tickets",
351 "data": {"tickets": self._approval.get_pending_tickets()},
352 }
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 }
372 if msg_type == "ping":
373 return {"type": "pong"}
375 return {"type": "error", "data": {"message": f"未知消息类型: {msg_type}"}}
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
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 }
405 def serve(self) -> None:
406 import uvicorn
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")
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()