open-edison 0.1.17__py3-none-any.whl → 0.1.26__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-edison
3
- Version: 0.1.17
3
+ Version: 0.1.26
4
4
  Summary: Open-source MCP security, aggregation, and monitoring. Single-user, self-hosted MCP proxy.
5
5
  Author-email: Hugo Berg <hugo@edison.watch>
6
6
  License-File: LICENSE
@@ -25,11 +25,42 @@ Requires-Dist: pytest>=8.3.3; extra == 'dev'
25
25
  Requires-Dist: ruff>=0.12.3; extra == 'dev'
26
26
  Description-Content-Type: text/markdown
27
27
 
28
- # OpenEdison
28
+ # OpenEdison 🔒⚡️
29
29
 
30
- Open-source MCP security gateway that prevents data exfiltration—via direct access or tool chaining—with full monitoring for local single‑user deployments. Provides core functionality of <https://edison.watch> for local, single-user use.
30
+ MCP security gateway that prevents data exfiltration—via direct access or tool chaining—with full monitoring for local single‑user deployments. Provides core functionality of <https://edison.watch> for local use.
31
31
 
32
- Just want to run it?
32
+ <p align="center">
33
+ <img src="media/trifecta520p.gif" alt="Trifecta Security Risk Animation" width="520">
34
+ </p>
35
+
36
+ <div align="center">
37
+ <h2>📧 To get visibility, control and exfiltration blocker into AI's interaction with your company software, systems of record, DBs, <a href="mailto:hello@edison.watch">Contact us</a> to discuss.</h2>
38
+ </div>
39
+
40
+ <p align="center">
41
+ <img alt="Project Version" src="https://img.shields.io/pypi/v/open-edison?label=version&color=blue">
42
+ <img alt="Python Version" src="https://img.shields.io/badge/python-3.12-blue?logo=python">
43
+ <img src="https://img.shields.io/badge/License-GPLv3-blue" alt="License">
44
+
45
+
46
+ </p>
47
+
48
+ ---
49
+
50
+
51
+ ## Features ✨
52
+
53
+ - 🛑 **Prevent Data Leaks** - Edison automatically blocks any data leaks, even if your AI gets jailbroken
54
+ - 👤 **Single-user MCP proxy** - No multi-user complexity, just a simple proxy for your MCP servers
55
+ - 🗂️ **JSON configuration** - Easy to configure and manage your MCP servers
56
+ - 🖥️ **Simple local frontend** - Track and monitor your MCP interactions, servers, and sessions.
57
+ - 📊 **Session tracking** - Track and monitor your MCP interactions
58
+ - 🔗 **Simple API** - REST API for managing MCP servers and proxying requests
59
+ - 🐳 **Docker support** - Run in a container for easy deployment
60
+
61
+ ## Quick Start 🚀
62
+
63
+ The fastest way to get started:
33
64
 
34
65
  ```bash
35
66
  # Installs uv (via Astral installer) and launches open-edison with uvx.
@@ -39,36 +70,31 @@ curl -fsSL https://raw.githubusercontent.com/Edison-Watch/open-edison/main/curl_
39
70
 
40
71
  Run locally with uvx: `uvx open-edison --config-dir ~/edison-config`
41
72
 
73
+ <details>
74
+ <summary>⬇️ Install Node.js/npm (optional for MCP tools)</summary>
75
+
42
76
  If you need `npx` (for Node-based MCP tools like `mcp-remote`), install Node.js as well:
43
77
 
44
- - macOS:
45
- - uv: `curl -fsSL https://astral.sh/uv/install.sh | sh`
46
- - Node/npx: `brew install node`
47
- - Linux (Debian/Ubuntu):
48
- - uv: `curl -fsSL https://astral.sh/uv/install.sh | sh`
49
- - Node/npx: `sudo apt-get update && sudo apt-get install -y nodejs npm`
50
- - Windows (PowerShell):
51
- - uv: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`
52
- - Node/npx: `winget install -e --id OpenJS.NodeJS`
78
+ ![macOS](https://img.shields.io/badge/mac%20os-000000?style=for-the-badge&logo=apple&logoColor=white)
53
79
 
54
- After installation, ensure that `npx` is available on PATH.
80
+ - uv: `curl -fsSL https://astral.sh/uv/install.sh | sh`
81
+ - Node/npx: `brew install node`
55
82
 
56
- <div align="center">
57
- <h2>📧 Interested in connecting AI to your business software with proper access controls? <a href="mailto:hello@edison.watch">Contact us</a> to discuss.</h2>
58
- </div>
83
+ ![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)
84
+
85
+ - uv: `curl -fsSL https://astral.sh/uv/install.sh | sh`
86
+ - Node/npx: `sudo apt-get update && sudo apt-get install -y nodejs npm`
59
87
 
60
- ## Features
88
+ ![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white)
61
89
 
62
- - **Single-user MCP proxy** - No multi-user complexity, just a simple proxy for your MCP servers
63
- - **JSON configuration** - Easy to configure and manage your MCP servers
64
- - **Simple local frontend** - Track and monitor your MCP interactions, servers, and sessions.
65
- - **Session tracking** - Track and monitor your MCP interactions
66
- - **Simple API** - REST API for managing MCP servers and proxying requests
67
- - **Docker support** - Run in a container for easy deployment
90
+ - uv: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`
91
+ - Node/npx: `winget install -e --id OpenJS.NodeJS`
68
92
 
69
- ## Quick Start
93
+ After installation, ensure that `npx` is available on PATH.
94
+ </details>
70
95
 
71
- ### Install from PyPI
96
+ <details>
97
+ <summary><img src="https://img.shields.io/badge/pypi-3775A9?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"> Install from PyPI</summary>
72
98
 
73
99
  #### Prerequisites
74
100
 
@@ -91,31 +117,37 @@ open-edison run --config-dir ~/edison-config
91
117
  OPEN_EDISON_CONFIG_DIR=~/edison-config open-edison run
92
118
  ```
93
119
 
94
- ### Run with Docker
120
+ </details>
121
+
122
+ <details>
123
+ <summary><img src="https://img.shields.io/badge/Docker-2CA5E0?style=for-the-badge&logo=docker&logoColor=white" alt="Docker"> Run with Docker</summary>
95
124
 
96
125
  There is a dockerfile for simple local setup.
97
126
 
98
127
  ```bash
99
128
  # Single-line:
100
- git clone https://github.com/GatlingX/open-edison.git && cd open-edison && make docker_run
129
+ git clone https://github.com/Edison-Watch/open-edison.git && cd open-edison && make docker_run
101
130
 
102
131
  # Or
103
132
  # Clone repo
104
- git clone https://github.com/GatlingX/open-edison.git
133
+ git clone https://github.com/Edison-Watch/open-edison.git
105
134
  # Enter repo
106
135
  cd open-edison
107
136
  # Build and run
108
137
  make docker_run
109
138
  ```
110
139
 
111
- The MCP server will be available at `http://localhost:3000` and the api + frontend at `http://localhost:3001`.
140
+ The MCP server will be available at `http://localhost:3000` and the api + frontend at `http://localhost:3001`. 🌐
112
141
 
113
- ### Run from source
142
+ </details>
143
+
144
+ <details>
145
+ <summary>⚙️ Run from source</summary>
114
146
 
115
147
  1. Clone the repository:
116
148
 
117
149
  ```bash
118
- git clone https://github.com/GatlingX/open-edison.git
150
+ git clone https://github.com/Edison-Watch/open-edison.git
119
151
  cd open-edison
120
152
  ```
121
153
 
@@ -146,9 +178,12 @@ make run
146
178
  open-edison run
147
179
  ```
148
180
 
149
- The server will be available at `http://localhost:3000`.
181
+ The server will be available at `http://localhost:3000`. 🌐
182
+
183
+ </details>
150
184
 
151
- ## MCP Connection
185
+ <details>
186
+ <summary>🔌 MCP Connection</summary>
152
187
 
153
188
  Connect any MCP client to Open Edison (requires Node.js/npm for `npx`):
154
189
 
@@ -169,19 +204,23 @@ Or add to your MCP client config:
169
204
  }
170
205
  ```
171
206
 
172
- ## Usage
207
+ </details>
208
+
209
+ <details>
210
+ <summary>🧭 Usage</summary>
173
211
 
174
212
  ### API Endpoints
175
213
 
176
214
  See [API Reference](docs/quick-reference/api_reference.md) for full API documentation.
177
215
 
178
- ## Development
216
+ <details>
217
+ <summary>🛠️ Development</summary>
179
218
 
180
- ### Setup
219
+ ### Setup 🧰
181
220
 
182
221
  Setup from source as above.
183
222
 
184
- ### Run
223
+ ### Run ▶️
185
224
 
186
225
  Server doesn't have any auto-reload at the moment, so you'll need to run & ctrl-c this during development.
187
226
 
@@ -189,7 +228,7 @@ Server doesn't have any auto-reload at the moment, so you'll need to run & ctrl-
189
228
  make run
190
229
  ```
191
230
 
192
- ### Tests/code quality
231
+ ### Tests/code quality
193
232
 
194
233
  We expect `make ci` to return cleanly.
195
234
 
@@ -197,7 +236,12 @@ We expect `make ci` to return cleanly.
197
236
  make ci
198
237
  ```
199
238
 
200
- ## Configuration
239
+ </details>
240
+
241
+ <details>
242
+ <summary>⚙️ Configuration (config.json)</summary>
243
+
244
+ ## Configuration ⚙️
201
245
 
202
246
  The `config.json` file contains all configuration:
203
247
 
@@ -215,21 +259,32 @@ Each MCP server configuration includes:
215
259
  - `env` - Environment variables (optional)
216
260
  - `enabled` - Whether to auto-start this server
217
261
 
218
- ## Security & Permissions System
262
+ </details>
219
263
 
220
- Open Edison includes a comprehensive security monitoring system that tracks the "lethal trifecta" of AI agent risks:
264
+ </details>
265
+
266
+ ## 🔐 How Edison prevents data leakages
267
+
268
+ <details>
269
+ <summary>🔱 The lethal trifecta, agent lifecycle management</summary>
270
+
271
+ Open Edison includes a comprehensive security monitoring system that tracks the "lethal trifecta" of AI agent risks, as described in [Simon Willison's blog post](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/):
272
+
273
+ <img src="media/lethal-trifecta.png" alt="The lethal trifecta diagram showing the three key AI agent security risks" width="70%">
221
274
 
222
275
  1. **Private data access** - Access to sensitive local files/data
223
276
  2. **Untrusted content exposure** - Exposure to external/web content
224
277
  3. **External communication** - Ability to write/send data externally
225
278
 
279
+ <img src="media/pam-diagram.png" alt="Privileged Access Management (PAM) example showing the lethal trifecta in action" width="90%">
280
+
226
281
  The configuration allows you to classify these risks across **tools**, **resources**, and **prompts** using separate configuration files.
227
282
 
228
283
  In addition to trifecta, we track Access Control Level (ACL) for each tool call,
229
284
  that is, each tool has an ACL level (one of PUBLIC, PRIVATE, or SECRET), and we track the highest ACL level for each session.
230
285
  If a write operation is attempted to a lower ACL level, it is blocked.
231
286
 
232
- ### Tool Permissions (`tool_permissions.json`)
287
+ ### 🧰 Tool Permissions (`tool_permissions.json`)
233
288
 
234
289
  Defines security classifications for MCP tools. See full file: [tool_permissions.json](tool_permissions.json), it looks like:
235
290
 
@@ -246,6 +301,9 @@ Defines security classifications for MCP tools. See full file: [tool_permissions
246
301
  }
247
302
  ```
248
303
 
304
+ <details>
305
+ <summary>📁 Resource Permissions (`resource_permissions.json`)</summary>
306
+
249
307
  ### Resource Permissions (`resource_permissions.json`)
250
308
 
251
309
  Defines security classifications for resource access patterns. See full file: [resource_permissions.json](resource_permissions.json), it looks like:
@@ -257,6 +315,11 @@ Defines security classifications for resource access patterns. See full file: [r
257
315
  }
258
316
  ```
259
317
 
318
+ </details>
319
+
320
+ <details>
321
+ <summary>💬 Prompt Permissions (`prompt_permissions.json`)</summary>
322
+
260
323
  ### Prompt Permissions (`prompt_permissions.json`)
261
324
 
262
325
  Defines security classifications for prompt types. See full file: [prompt_permissions.json](prompt_permissions.json), it looks like:
@@ -268,7 +331,9 @@ Defines security classifications for prompt types. See full file: [prompt_permis
268
331
  }
269
332
  ```
270
333
 
271
- ### Wildcard Patterns
334
+ </details>
335
+
336
+ ### Wildcard Patterns ✨
272
337
 
273
338
  All permission types support wildcard patterns:
274
339
 
@@ -276,21 +341,29 @@ All permission types support wildcard patterns:
276
341
  - **Resources**: `scheme:*` (e.g., `file:*` matches all file resources)
277
342
  - **Prompts**: `type:*` (e.g., `template:*` matches all template prompts)
278
343
 
279
- ### Security Monitoring
344
+ ### Security Monitoring 🕵️
280
345
 
281
346
  **All items must be explicitly configured** - unknown tools/resources/prompts will be rejected for security.
282
347
 
283
348
  Use the `get_security_status` tool to monitor your session's current risk level and see which capabilities have been accessed. When the lethal trifecta is achieved (all three risk flags set), further potentially dangerous operations are blocked.
284
349
 
285
- ## Documentation
350
+ </details>
351
+
352
+
353
+
354
+ ## Documentation 📚
286
355
 
287
356
  📚 **Complete documentation available in [`docs/`](docs/)**
288
357
 
289
- - **[Getting Started](docs/quick-reference/config_quick_start.md)** - Quick setup guide
290
- - **[Configuration](docs/core/configuration.md)** - Complete configuration reference
291
- - **[API Reference](docs/quick-reference/api_reference.md)** - REST API documentation
292
- - **[Development Guide](docs/development/development_guide.md)** - Contributing and development
358
+ - 🚀 **[Getting Started](docs/quick-reference/config_quick_start.md)** - Quick setup guide
359
+ - ⚙️ **[Configuration](docs/core/configuration.md)** - Complete configuration reference
360
+ - 📡 **[API Reference](docs/quick-reference/api_reference.md)** - REST API documentation
361
+ - 🧑‍💻 **[Development Guide](docs/development/development_guide.md)** - Contributing and development
293
362
 
294
- ## License
363
+
364
+ <details>
365
+ <summary>📄 License</summary>
295
366
 
296
367
  GPL-3.0 License - see [LICENSE](LICENSE) for details.
368
+
369
+ </details>
@@ -0,0 +1,17 @@
1
+ src/__init__.py,sha256=QWeZdjAm2D2B0eWhd8m2-DPpWvIP26KcNJxwEoU1oEQ,254
2
+ src/__main__.py,sha256=kQsaVyzRa_ESC57JpKDSQJAHExuXme0rM5beJsYxFeA,161
3
+ src/cli.py,sha256=_F1xtUU2h4snWUHf1NptRWGQaD2OSIhEPGLh9Rzmtis,10032
4
+ src/config.py,sha256=jZYX4q09hg2VlLCq7FIKa_bL7NpNNMStYMQyEMZPrDg,9910
5
+ src/events.py,sha256=rBH7rnaSWZ7GIC8zyBTwpcvIKWmKYCki-DNGgJhxPow,5001
6
+ src/oauth_manager.py,sha256=qcQa5BDRZr4bjqiXNflCnrXOh9mo9JVjvP2Caseg2Uc,9943
7
+ src/permissions.py,sha256=kIbLPtaJAwV5CF-YECLhU7HEF704LdQCI2xIh-TCu4I,10834
8
+ src/server.py,sha256=Gg2DnnvZr59e0PGtRXVD94fxVxtiNnizlIrVkeLGIok,44504
9
+ src/single_user_mcp.py,sha256=bbWbuuBxjL0DJY9kAHtHt8jwQNePXPa4cD4gnbD9XmE,16897
10
+ src/telemetry.py,sha256=-RZPIjpI53zbsKmp-63REeZ1JirWHV5WvpSRa2nqZEk,11321
11
+ src/middleware/data_access_tracker.py,sha256=bArBffWgYmvxOx9z_pgXQhogvnWQcc1m6WvEblDD4gw,15039
12
+ src/middleware/session_tracking.py,sha256=p3UMruIe5RBYnfAzmSaHMOeN3xKN-9EiCSd7nAGjgrw,22138
13
+ open_edison-0.1.26.dist-info/METADATA,sha256=Tfw-vOv_KlIvUBMcU5DjwHNiksFxJejJBurC0dp_b4Q,11685
14
+ open_edison-0.1.26.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ open_edison-0.1.26.dist-info/entry_points.txt,sha256=qNAkJcnoTXRhj8J--3PDmXz_TQKdB8H_0C9wiCtDIyA,72
16
+ open_edison-0.1.26.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
17
+ open_edison-0.1.26.dist-info/RECORD,,
src/cli.py CHANGED
@@ -177,6 +177,7 @@ def _spawn_frontend_dev( # noqa: C901 - pragmatic complexity for env probing
177
177
 
178
178
 
179
179
  async def _run_server(args: Any) -> None:
180
+ # TODO check this works as we want it to
180
181
  # Resolve config dir and expose via env for the rest of the app
181
182
  config_dir_arg = getattr(args, "config_dir", None)
182
183
  if config_dir_arg is not None:
@@ -184,7 +185,7 @@ async def _run_server(args: Any) -> None:
184
185
  config_dir = get_config_dir()
185
186
 
186
187
  # Load config after setting env override
187
- cfg = Config.load()
188
+ cfg = Config(config_dir)
188
189
 
189
190
  host = getattr(args, "host", None) or cfg.server.host
190
191
  port = getattr(args, "port", None) or cfg.server.port
src/config.py CHANGED
@@ -58,12 +58,6 @@ def get_config_dir() -> Path:
58
58
  return (Path.home() / ".open-edison").resolve()
59
59
 
60
60
 
61
- # Back-compat private alias (internal modules may import this)
62
- def _get_config_dir() -> Path: # noqa: D401
63
- """Alias to public get_config_dir (maintained for internal imports)."""
64
- return get_config_dir()
65
-
66
-
67
61
  def _default_config_path() -> Path:
68
62
  """Determine default config.json path.
69
63
 
@@ -75,22 +69,18 @@ def _default_config_path() -> Path:
75
69
  repo_config = root_dir / "config.json"
76
70
 
77
71
  # If pyproject.toml exists next to src/, we are likely in a repo checkout
72
+ # Prefer the user config directory if a config already exists there (runtime source of truth),
73
+ # otherwise fall back to the repo copy.
78
74
  if repo_pyproject.exists():
75
+ user_cfg = get_config_dir() / "config.json"
76
+ if user_cfg.exists():
77
+ return user_cfg
79
78
  return repo_config
80
79
 
81
- # Otherwise, prefer user config directory
80
+ # Installed package: prefer user config directory
82
81
  return get_config_dir() / "config.json"
83
82
 
84
83
 
85
- class ConfigError(Exception):
86
- """Exception raised for configuration-related errors"""
87
-
88
- def __init__(self, message: str, config_path: Path | None = None):
89
- self.message = message
90
- self.config_path = config_path
91
- super().__init__(self.message)
92
-
93
-
94
84
  @dataclass
95
85
  class ServerConfig:
96
86
  """Server configuration"""
@@ -119,10 +109,41 @@ class MCPServerConfig:
119
109
  enabled: bool = True
120
110
  roots: list[str] | None = None
121
111
 
112
+ oauth_scopes: list[str] | None = None
113
+ """OAuth scopes to request for this server."""
114
+
115
+ oauth_client_name: str | None = None
116
+ """Custom client name for OAuth registration."""
117
+
122
118
  def __post_init__(self):
123
119
  if self.env is None:
124
120
  self.env = {}
125
121
 
122
+ def is_remote_server(self) -> bool:
123
+ """
124
+ Check if this is a remote MCP server (connects to external HTTPS endpoint).
125
+
126
+ Remote servers use mcp-remote with HTTPS URLs and may require OAuth.
127
+ Local servers run as child processes and don't need OAuth.
128
+ """
129
+ return (
130
+ self.command == "npx"
131
+ and len(self.args) >= 3
132
+ and self.args[1] == "mcp-remote"
133
+ and self.args[2].startswith("https://")
134
+ )
135
+
136
+ def get_remote_url(self) -> str | None:
137
+ """
138
+ Get the remote URL for a remote MCP server.
139
+
140
+ Returns:
141
+ The HTTPS URL if this is a remote server, None otherwise
142
+ """
143
+ if self.is_remote_server():
144
+ return self.args[2]
145
+ return None
146
+
126
147
 
127
148
  @dataclass
128
149
  class TelemetryConfig:
@@ -160,8 +181,7 @@ class Config:
160
181
  log.warning(f"Failed to read version from pyproject.toml: {e}")
161
182
  return "unknown"
162
183
 
163
- @classmethod
164
- def load(cls, config_path: Path | None = None) -> "Config":
184
+ def __init__(self, config_path: Path | None = None) -> None:
165
185
  """Load configuration from JSON file.
166
186
 
167
187
  If a directory path is provided, will look for `config.json` inside it.
@@ -174,11 +194,12 @@ class Config:
174
194
  if config_path.is_dir():
175
195
  config_path = config_path / "config.json"
176
196
 
197
+ log.info(f"Loading configuration from {config_path}")
198
+
177
199
  if not config_path.exists():
178
200
  log.warning(f"Config file not found at {config_path}, creating default config")
179
- default_config = cls.create_default()
180
- default_config.save(config_path)
181
- return default_config
201
+ self.create_default()
202
+ self.save(config_path)
182
203
 
183
204
  with open(config_path) as f:
184
205
  data: dict[str, Any] = json.load(f)
@@ -216,15 +237,13 @@ class Config:
216
237
  export_interval_ms=export_interval_ms,
217
238
  )
218
239
 
219
- return cls(
220
- server=ServerConfig(**server_data), # type: ignore
221
- logging=LoggingConfig(**logging_data), # type: ignore
222
- mcp_servers=[
223
- MCPServerConfig(**server_item) # type: ignore
224
- for server_item in mcp_servers_data # type: ignore
225
- ],
226
- telemetry=telemetry_cfg,
227
- )
240
+ self.server = ServerConfig(**server_data) # type: ignore
241
+ self.logging = LoggingConfig(**logging_data) # type: ignore
242
+ self.mcp_servers = [
243
+ MCPServerConfig(**server_item) # type: ignore
244
+ for server_item in mcp_servers_data # type: ignore
245
+ ]
246
+ self.telemetry = telemetry_cfg
228
247
 
229
248
  def save(self, config_path: Path | None = None) -> None:
230
249
  """Save configuration to JSON file"""
@@ -251,26 +270,19 @@ class Config:
251
270
 
252
271
  log.info(f"Configuration saved to {config_path}")
253
272
 
254
- @classmethod
255
- def create_default(cls) -> "Config":
273
+ def create_default(self) -> None:
256
274
  """Create default configuration"""
257
- return cls(
258
- server=ServerConfig(),
259
- logging=LoggingConfig(),
260
- mcp_servers=[
261
- MCPServerConfig(
262
- name="filesystem",
263
- command="uvx",
264
- args=["mcp-server-filesystem", "/tmp"],
265
- enabled=False,
266
- )
267
- ],
268
- telemetry=TelemetryConfig(
269
- enabled=True,
270
- otlp_endpoint=DEFAULT_OTLP_METRICS_ENDPOINT,
271
- ),
275
+ self.server = ServerConfig()
276
+ self.logging = LoggingConfig()
277
+ self.mcp_servers = [
278
+ MCPServerConfig(
279
+ name="filesystem",
280
+ command="uvx",
281
+ args=["mcp-server-filesystem", "/tmp"],
282
+ enabled=False,
283
+ )
284
+ ]
285
+ self.telemetry = TelemetryConfig(
286
+ enabled=True,
287
+ otlp_endpoint=DEFAULT_OTLP_METRICS_ENDPOINT,
272
288
  )
273
-
274
-
275
- # Load global configuration
276
- config = Config.load()
src/events.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ Lightweight in-process event broadcasting for Open Edison (SSE-friendly).
3
+
4
+ Provides a simple publisher/subscriber model to stream JSON events to
5
+ connected dashboard clients over Server-Sent Events (SSE).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from collections.abc import AsyncIterator, Callable
13
+ from functools import wraps
14
+ from typing import Any
15
+
16
+ from loguru import logger as log
17
+
18
+ _subscribers: set[asyncio.Queue[str]] = set()
19
+ _lock = asyncio.Lock()
20
+
21
+ # One-time approvals for (session_id, kind, name)
22
+ _approvals: dict[str, asyncio.Event] = {}
23
+ _approvals_lock = asyncio.Lock()
24
+
25
+
26
+ def _approval_key(session_id: str, kind: str, name: str) -> str:
27
+ return f"{session_id}::{kind}::{name}"
28
+
29
+
30
+ def requires_loop(func: Callable[..., Any]) -> Callable[..., None | Any]: # noqa: ANN401
31
+ """Decorator to ensure the function is called when there is an asyncio event loop.
32
+ This is for sync(!) functions that return None / can do so on error"""
33
+
34
+ @wraps(func)
35
+ def wrapper(*args: Any, **kwargs: Any) -> None | Any:
36
+ if asyncio.get_event_loop_policy()._local._loop is None: # type: ignore[attr-defined]
37
+ log.warning("fire_and_forget called in non-async context")
38
+ return None
39
+ return func(*args, **kwargs)
40
+
41
+ return wrapper
42
+
43
+
44
+ async def subscribe() -> asyncio.Queue[str]:
45
+ """Register a new subscriber and return its queue of SSE strings."""
46
+ queue: asyncio.Queue[str] = asyncio.Queue(maxsize=100)
47
+ async with _lock:
48
+ _subscribers.add(queue)
49
+ log.debug(f"SSE subscriber added (total={len(_subscribers)})")
50
+ return queue
51
+
52
+
53
+ async def unsubscribe(queue: asyncio.Queue[str]) -> None:
54
+ """Remove a subscriber and drain its queue."""
55
+ async with _lock:
56
+ _subscribers.discard(queue)
57
+ log.debug(f"SSE subscriber removed (total={len(_subscribers)})")
58
+ try:
59
+ while not queue.empty():
60
+ _ = queue.get_nowait()
61
+ except Exception:
62
+ pass
63
+
64
+
65
+ async def publish(event: dict[str, Any]) -> None:
66
+ """Publish a JSON event to all subscribers.
67
+
68
+ The event is serialized and wrapped as an SSE data frame.
69
+ """
70
+ try:
71
+ data = json.dumps(event, ensure_ascii=False)
72
+ except Exception as e: # noqa: BLE001
73
+ log.error(f"Failed to serialize event for SSE: {e}")
74
+ return
75
+
76
+ frame = f"data: {data}\n\n"
77
+ async with _lock:
78
+ dead: list[asyncio.Queue[str]] = []
79
+ for q in _subscribers:
80
+ try:
81
+ # Best-effort non-blocking put; drop if full to avoid backpressure
82
+ if q.full():
83
+ _ = q.get_nowait()
84
+ q.put_nowait(frame)
85
+ except Exception:
86
+ dead.append(q)
87
+ for q in dead:
88
+ _subscribers.discard(q)
89
+
90
+
91
+ @requires_loop
92
+ def fire_and_forget(event: dict[str, Any]) -> None:
93
+ """Schedule publish(event) and log any exception when the task completes."""
94
+ task = asyncio.create_task(publish(event))
95
+
96
+ def _log_exc(t: asyncio.Task[None]) -> None:
97
+ try:
98
+ _ = t.exception()
99
+ if _ is not None:
100
+ log.error(f"SSE publish failed: {_}")
101
+ except Exception as e: # noqa: BLE001
102
+ log.error(f"SSE publish done-callback error: {e}")
103
+
104
+ task.add_done_callback(_log_exc)
105
+
106
+
107
+ async def approve_once(session_id: str, kind: str, name: str) -> None:
108
+ """Approve a single pending operation for this session/kind/name.
109
+
110
+ This unblocks exactly one waiter if present (and future waiters will create a new Event).
111
+ """
112
+ key = _approval_key(session_id, kind, name)
113
+ async with _approvals_lock:
114
+ ev = _approvals.get(key)
115
+ if ev is None:
116
+ ev = asyncio.Event()
117
+ _approvals[key] = ev
118
+ ev.set()
119
+
120
+
121
+ async def wait_for_approval(session_id: str, kind: str, name: str, timeout_s: float = 30.0) -> bool:
122
+ """Wait up to timeout for approval. Consumes the approval if granted."""
123
+ key = _approval_key(session_id, kind, name)
124
+ async with _approvals_lock:
125
+ ev = _approvals.get(key)
126
+ if ev is None:
127
+ ev = asyncio.Event()
128
+ _approvals[key] = ev
129
+ try:
130
+ await asyncio.wait_for(ev.wait(), timeout=timeout_s)
131
+ return True
132
+ except TimeoutError:
133
+ return False
134
+ finally:
135
+ # Consume the event so it does not auto-approve future waits
136
+ async with _approvals_lock:
137
+ _approvals.pop(key, None)
138
+
139
+
140
+ async def sse_stream(queue: asyncio.Queue[str]) -> AsyncIterator[bytes]:
141
+ """Yield SSE frames from the given queue with periodic heartbeats."""
142
+ try:
143
+ # Initial comment to open the stream
144
+ yield b": connected\n\n"
145
+ while True:
146
+ try:
147
+ frame = await asyncio.wait_for(queue.get(), timeout=15.0)
148
+ yield frame.encode("utf-8")
149
+ except TimeoutError:
150
+ # Heartbeat to keep the connection alive
151
+ yield b": ping\n\n"
152
+ finally:
153
+ await unsubscribe(queue)