1"""

2 Desktop Server — AgentOS 桌面客户端后端 v1.7.1。

3

4 功能:

5- FastAPI HTTP API（文件浏览、Shell 执行、Agent 对话、授权审批）

6- WebSocket 实时推送（含可视化授权卡片）

7- 静态文件服务（前端 SPA）

8- System 模块集成（权限分层 + 可视化授权审批引擎）

9"""

10

11 from __future__ import annotations

12

13 import os

14 import json

15 import uuid

16 from dataclasses import dataclass

17 from typing import Optional

18

19 from agentos.system.permissions import (

20 SystemPermissionManager,

21 PermissionTier,

22 SAFE_PERMISSIONS,

23 DEV_PERMISSIONS,

24)

25 from agentos.system.file_ops import FileOperator, FileOpResult

26 from agentos.system.shell_exec import ShellExecutor

27 from agentos.system.approval import ApprovalEngine

28 from agentos.enterprise.api_keys import (

29 APIKeyManager, KeyScope, KeyCreateRequest, APIKey,

30)

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

32

33 APP_VERSION = "1.7.1"

34

35

36@dataclass

37 class 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

46 class 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

388 def 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()