abstractgateway 0.1.0__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.
@@ -0,0 +1,504 @@
1
+ """Gateway security middleware (backlog 309).
2
+
3
+ Security goals (v0):
4
+ - Bearer token auth for gateway endpoints (mutating + read endpoints by default).
5
+ - Origin allowlist checks when Origin is present (DNS rebinding / browser-origin defense).
6
+ - Abuse resistance: request body limits, concurrency limits, auth failure lockouts.
7
+
8
+ Design constraints:
9
+ - Must not break durability semantics (command idempotency, replay-first ledger).
10
+ - Must stay dependency-light (stdlib + Starlette/FastAPI already in the server).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import hmac
17
+ import json
18
+ import logging
19
+ import os
20
+ import threading
21
+ import time
22
+ from dataclasses import dataclass
23
+ from typing import Any, Dict, Iterable, Optional, Tuple
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def _as_bool(raw: Any, default: bool = False) -> bool:
30
+ if raw is None:
31
+ return default
32
+ if isinstance(raw, bool):
33
+ return raw
34
+ s = str(raw).strip().lower()
35
+ if not s:
36
+ return default
37
+ if s in {"1", "true", "yes", "on"}:
38
+ return True
39
+ if s in {"0", "false", "no", "off"}:
40
+ return False
41
+ return default
42
+
43
+
44
+ def _split_csv(raw: Optional[str]) -> list[str]:
45
+ if raw is None:
46
+ return []
47
+ out: list[str] = []
48
+ for part in str(raw).split(","):
49
+ p = part.strip()
50
+ if p:
51
+ out.append(p)
52
+ return out
53
+
54
+
55
+ def _is_loopback_ip(host: str) -> bool:
56
+ h = str(host or "").strip().lower()
57
+ return h in {"127.0.0.1", "::1", "localhost", "testclient"}
58
+
59
+
60
+ def _env(name: str, fallback: Optional[str] = None) -> Optional[str]:
61
+ v = os.getenv(name)
62
+ if v is not None and str(v).strip():
63
+ return v
64
+ if fallback:
65
+ v2 = os.getenv(fallback)
66
+ if v2 is not None and str(v2).strip():
67
+ return v2
68
+ return None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class GatewayAuthPolicy:
73
+ """Configuration for the Run Gateway security layer."""
74
+
75
+ # Enable/disable middleware entirely (escape hatch).
76
+ enabled: bool = True
77
+
78
+ # Auth tokens (shared secret list; any token is accepted).
79
+ tokens: Tuple[str, ...] = ()
80
+
81
+ # Default: protect both reads and writes.
82
+ protect_read_endpoints: bool = True
83
+ protect_write_endpoints: bool = True
84
+
85
+ # Dev-only convenience: allow unauthenticated reads *from loopback only*.
86
+ dev_allow_unauthenticated_reads_on_loopback: bool = False
87
+
88
+ # Origin allowlist. Only applied when Origin header is present.
89
+ # Supports '*' suffix wildcard for prefix matches (e.g., 'http://localhost:*').
90
+ allowed_origins: Tuple[str, ...] = ("http://localhost:*", "http://127.0.0.1:*")
91
+
92
+ # Abuse resistance
93
+ max_body_bytes: int = 256_000
94
+ max_concurrency: int = 64
95
+ max_sse_connections: int = 32
96
+
97
+ # Auth failure lockout
98
+ lockout_after_failures: int = 5
99
+ lockout_base_s: float = 1.0
100
+ lockout_max_s: float = 60.0
101
+
102
+ # Proxy trust (X-Forwarded-For)
103
+ trust_proxy: bool = False
104
+
105
+
106
+ def load_gateway_auth_policy_from_env() -> GatewayAuthPolicy:
107
+ """Load GatewayAuthPolicy from environment variables.
108
+
109
+ Canonical env vars:
110
+ - ABSTRACTGATEWAY_SECURITY=1|0
111
+ - ABSTRACTGATEWAY_AUTH_TOKEN / ABSTRACTGATEWAY_AUTH_TOKENS (comma-separated)
112
+ - ABSTRACTGATEWAY_PROTECT_READ=1|0
113
+ - ABSTRACTGATEWAY_PROTECT_WRITE=1|0
114
+ - ABSTRACTGATEWAY_DEV_READ_NO_AUTH=1|0 (loopback only)
115
+ - ABSTRACTGATEWAY_ALLOWED_ORIGINS (comma-separated; supports '*' suffix wildcard)
116
+ - ABSTRACTGATEWAY_MAX_BODY_BYTES
117
+ - ABSTRACTGATEWAY_MAX_CONCURRENCY
118
+ - ABSTRACTGATEWAY_MAX_SSE
119
+ - ABSTRACTGATEWAY_LOCKOUT_AFTER
120
+ - ABSTRACTGATEWAY_LOCKOUT_BASE_S
121
+ - ABSTRACTGATEWAY_LOCKOUT_MAX_S
122
+ - ABSTRACTGATEWAY_TRUST_PROXY=1|0
123
+
124
+ Compatibility fallbacks (legacy):
125
+ - ABSTRACTFLOW_GATEWAY_*
126
+ """
127
+
128
+ enabled = _as_bool(_env("ABSTRACTGATEWAY_SECURITY", "ABSTRACTFLOW_GATEWAY_SECURITY") or "1", True)
129
+
130
+ tokens = []
131
+ tokens.extend(_split_csv(_env("ABSTRACTGATEWAY_AUTH_TOKEN", "ABSTRACTFLOW_GATEWAY_AUTH_TOKEN")))
132
+ tokens.extend(_split_csv(_env("ABSTRACTGATEWAY_AUTH_TOKENS", "ABSTRACTFLOW_GATEWAY_AUTH_TOKENS")))
133
+ # Deduplicate while preserving order
134
+ seen: set[str] = set()
135
+ tokens2: list[str] = []
136
+ for t in tokens:
137
+ if t not in seen:
138
+ seen.add(t)
139
+ tokens2.append(t)
140
+
141
+ protect_read = _as_bool(_env("ABSTRACTGATEWAY_PROTECT_READ", "ABSTRACTFLOW_GATEWAY_PROTECT_READ") or "1", True)
142
+ protect_write = _as_bool(_env("ABSTRACTGATEWAY_PROTECT_WRITE", "ABSTRACTFLOW_GATEWAY_PROTECT_WRITE") or "1", True)
143
+ dev_read_no_auth = _as_bool(_env("ABSTRACTGATEWAY_DEV_READ_NO_AUTH", "ABSTRACTFLOW_GATEWAY_DEV_READ_NO_AUTH") or "0", False)
144
+
145
+ allowed_origins_raw = _env("ABSTRACTGATEWAY_ALLOWED_ORIGINS", "ABSTRACTFLOW_GATEWAY_ALLOWED_ORIGINS")
146
+ allowed_origins = (
147
+ tuple(_split_csv(allowed_origins_raw))
148
+ if allowed_origins_raw
149
+ else ("http://localhost:*", "http://127.0.0.1:*")
150
+ )
151
+
152
+ def _as_int(name: str, fallback: str, default: int) -> int:
153
+ raw = _env(name, fallback)
154
+ if raw is None or not str(raw).strip():
155
+ return default
156
+ try:
157
+ return int(str(raw).strip())
158
+ except Exception:
159
+ return default
160
+
161
+ def _as_float(name: str, fallback: str, default: float) -> float:
162
+ raw = _env(name, fallback)
163
+ if raw is None or not str(raw).strip():
164
+ return default
165
+ try:
166
+ return float(str(raw).strip())
167
+ except Exception:
168
+ return default
169
+
170
+ max_body = _as_int("ABSTRACTGATEWAY_MAX_BODY_BYTES", "ABSTRACTFLOW_GATEWAY_MAX_BODY_BYTES", 256_000)
171
+ max_conc = _as_int("ABSTRACTGATEWAY_MAX_CONCURRENCY", "ABSTRACTFLOW_GATEWAY_MAX_CONCURRENCY", 64)
172
+ max_sse = _as_int("ABSTRACTGATEWAY_MAX_SSE", "ABSTRACTFLOW_GATEWAY_MAX_SSE", 32)
173
+ lockout_after = _as_int("ABSTRACTGATEWAY_LOCKOUT_AFTER", "ABSTRACTFLOW_GATEWAY_LOCKOUT_AFTER", 5)
174
+ lockout_base = _as_float("ABSTRACTGATEWAY_LOCKOUT_BASE_S", "ABSTRACTFLOW_GATEWAY_LOCKOUT_BASE_S", 1.0)
175
+ lockout_max = _as_float("ABSTRACTGATEWAY_LOCKOUT_MAX_S", "ABSTRACTFLOW_GATEWAY_LOCKOUT_MAX_S", 60.0)
176
+ trust_proxy = _as_bool(_env("ABSTRACTGATEWAY_TRUST_PROXY", "ABSTRACTFLOW_GATEWAY_TRUST_PROXY") or "0", False)
177
+
178
+ return GatewayAuthPolicy(
179
+ enabled=enabled,
180
+ tokens=tuple(tokens2),
181
+ protect_read_endpoints=bool(protect_read),
182
+ protect_write_endpoints=bool(protect_write),
183
+ dev_allow_unauthenticated_reads_on_loopback=bool(dev_read_no_auth),
184
+ allowed_origins=tuple(allowed_origins),
185
+ max_body_bytes=max(0, int(max_body)),
186
+ max_concurrency=max(1, int(max_conc)),
187
+ max_sse_connections=max(1, int(max_sse)),
188
+ lockout_after_failures=max(1, int(lockout_after)),
189
+ lockout_base_s=max(0.0, float(lockout_base)),
190
+ lockout_max_s=max(0.0, float(lockout_max)),
191
+ trust_proxy=bool(trust_proxy),
192
+ )
193
+
194
+
195
+ class _AuthLockoutTracker:
196
+ """In-memory auth failure tracker (v0).
197
+
198
+ This is intentionally process-local. In production, prefer infra-level rate limiting
199
+ at the reverse proxy + WAF, and treat this as a safety net.
200
+ """
201
+
202
+ def __init__(
203
+ self,
204
+ *,
205
+ after_failures: int,
206
+ base_s: float,
207
+ max_s: float,
208
+ max_entries: int = 10_000,
209
+ ) -> None:
210
+ self._after = max(1, int(after_failures))
211
+ self._base = max(0.0, float(base_s))
212
+ self._max = max(0.0, float(max_s))
213
+ self._max_entries = max(100, int(max_entries))
214
+ self._lock = threading.Lock()
215
+ # ip -> (fail_count, locked_until_epoch_s)
216
+ self._state: Dict[str, Tuple[int, float]] = {}
217
+
218
+ def check_locked(self, ip: str) -> Optional[int]:
219
+ now = time.time()
220
+ with self._lock:
221
+ fc, until = self._state.get(ip, (0, 0.0))
222
+ if until > now:
223
+ return int(max(0.0, until - now))
224
+ return None
225
+
226
+ def record_failure(self, ip: str) -> Optional[int]:
227
+ now = time.time()
228
+ with self._lock:
229
+ if len(self._state) > self._max_entries:
230
+ # best-effort pruning: drop arbitrary entries
231
+ for k in list(self._state.keys())[:1000]:
232
+ self._state.pop(k, None)
233
+ fc, until = self._state.get(ip, (0, 0.0))
234
+ fc += 1
235
+
236
+ if fc < self._after:
237
+ self._state[ip] = (fc, 0.0)
238
+ return None
239
+
240
+ # Exponential backoff from the threshold.
241
+ exp = max(0, fc - self._after)
242
+ lock_s = self._base * (2**exp) if self._base > 0 else 0.0
243
+ if self._max > 0:
244
+ lock_s = min(lock_s, self._max)
245
+ until2 = now + lock_s
246
+ self._state[ip] = (fc, until2)
247
+ return int(lock_s)
248
+
249
+ def record_success(self, ip: str) -> None:
250
+ with self._lock:
251
+ if ip in self._state:
252
+ self._state.pop(ip, None)
253
+
254
+
255
+ class GatewaySecurityMiddleware:
256
+ """ASGI middleware to secure /api/gateway/* endpoints."""
257
+
258
+ def __init__(self, app: Any, *, policy: GatewayAuthPolicy):
259
+ self._app = app
260
+ self._policy = policy
261
+ self._sema = asyncio.Semaphore(int(policy.max_concurrency))
262
+ self._sse_sema = asyncio.Semaphore(int(policy.max_sse_connections))
263
+ self._lockouts = _AuthLockoutTracker(
264
+ after_failures=policy.lockout_after_failures,
265
+ base_s=policy.lockout_base_s,
266
+ max_s=policy.lockout_max_s,
267
+ )
268
+
269
+ if policy.enabled:
270
+ if policy.protect_write_endpoints and not policy.tokens:
271
+ logger.warning(
272
+ "Gateway security enabled, but no auth token configured "
273
+ "(ABSTRACTGATEWAY_AUTH_TOKEN). Mutating endpoints will be rejected."
274
+ )
275
+
276
+ # ---------------------------
277
+ # Helpers
278
+ # ---------------------------
279
+
280
+ def _header(self, scope: dict, name: str) -> Optional[str]:
281
+ key = name.lower().encode("utf-8")
282
+ for k, v in scope.get("headers") or []:
283
+ if k == key:
284
+ try:
285
+ return v.decode("utf-8")
286
+ except Exception:
287
+ return None
288
+ return None
289
+
290
+ def _client_ip(self, scope: dict) -> str:
291
+ if self._policy.trust_proxy:
292
+ xff = self._header(scope, "x-forwarded-for")
293
+ if xff:
294
+ first = xff.split(",")[0].strip()
295
+ if first:
296
+ return first
297
+ client = scope.get("client")
298
+ if isinstance(client, (list, tuple)) and client and isinstance(client[0], str):
299
+ return client[0]
300
+ return "unknown"
301
+
302
+ def _origin_allowed(self, origin: str) -> bool:
303
+ o = str(origin or "").strip()
304
+ if not o:
305
+ return True
306
+ allowed = self._policy.allowed_origins or ()
307
+ if not allowed:
308
+ return False
309
+ for pattern in allowed:
310
+ p = str(pattern or "").strip()
311
+ if not p:
312
+ continue
313
+ if p == "*":
314
+ return True
315
+ if p.endswith("*"):
316
+ if o.startswith(p[:-1]):
317
+ return True
318
+ continue
319
+ if o == p:
320
+ return True
321
+ return False
322
+
323
+ def _token_valid(self, token: str) -> bool:
324
+ # Constant-time compare against any configured token.
325
+ for t in self._policy.tokens:
326
+ if hmac.compare_digest(str(token), str(t)):
327
+ return True
328
+ return False
329
+
330
+ async def _send_json(
331
+ self,
332
+ send,
333
+ *,
334
+ status: int,
335
+ payload: Dict[str, Any],
336
+ headers: Optional[list[tuple[bytes, bytes]]] = None,
337
+ ) -> None:
338
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
339
+ hdrs = [
340
+ (b"content-type", b"application/json; charset=utf-8"),
341
+ (b"content-length", str(len(body)).encode("utf-8")),
342
+ ]
343
+ if headers:
344
+ hdrs.extend(headers)
345
+ await send({"type": "http.response.start", "status": int(status), "headers": hdrs})
346
+ await send({"type": "http.response.body", "body": body})
347
+
348
+ async def _reject(
349
+ self, send, *, status: int, detail: str, headers: Optional[list[tuple[bytes, bytes]]] = None
350
+ ) -> None:
351
+ await self._send_json(send, status=status, payload={"detail": detail}, headers=headers)
352
+
353
+ async def _try_acquire(self, sem: asyncio.Semaphore, *, timeout_s: float = 0.01) -> bool:
354
+ try:
355
+ await asyncio.wait_for(sem.acquire(), timeout=float(timeout_s))
356
+ return True
357
+ except Exception:
358
+ return False
359
+
360
+ # ---------------------------
361
+ # ASGI entrypoint
362
+ # ---------------------------
363
+
364
+ async def __call__(self, scope, receive, send):
365
+ if scope.get("type") != "http":
366
+ return await self._app(scope, receive, send)
367
+
368
+ path = str(scope.get("path") or "")
369
+ if not path.startswith("/api/gateway"):
370
+ return await self._app(scope, receive, send)
371
+
372
+ if not self._policy.enabled:
373
+ return await self._app(scope, receive, send)
374
+
375
+ method = str(scope.get("method") or "GET").upper()
376
+ is_read = method in {"GET", "HEAD"}
377
+ is_write = method in {"POST", "PUT", "PATCH", "DELETE"}
378
+ ip = self._client_ip(scope)
379
+
380
+ # Origin checks (only when Origin is present).
381
+ origin = self._header(scope, "origin")
382
+ if origin is not None and not self._origin_allowed(origin):
383
+ await self._reject(send, status=403, detail="Forbidden (origin not allowed)")
384
+ return
385
+
386
+ # Lockout handling (only meaningful when auth is enabled).
387
+ locked = self._lockouts.check_locked(ip)
388
+ if locked is not None and locked > 0:
389
+ await self._reject(
390
+ send,
391
+ status=429,
392
+ detail="Too Many Requests (auth lockout)",
393
+ headers=[(b"retry-after", str(int(locked)).encode("utf-8"))],
394
+ )
395
+ return
396
+
397
+ # Auth decision.
398
+ auth_required = False
399
+ if is_write and self._policy.protect_write_endpoints:
400
+ auth_required = True
401
+ if is_read and self._policy.protect_read_endpoints:
402
+ # Optional dev escape hatch (loopback-only).
403
+ if self._policy.dev_allow_unauthenticated_reads_on_loopback and _is_loopback_ip(ip):
404
+ auth_required = False
405
+ else:
406
+ auth_required = True
407
+
408
+ # OPTIONS preflight: allow through (but still origin-checked above).
409
+ if method == "OPTIONS":
410
+ return await self._app(scope, receive, send)
411
+
412
+ if auth_required:
413
+ if not self._policy.tokens:
414
+ await self._reject(send, status=503, detail="Gateway auth required but no token configured")
415
+ return
416
+ auth = self._header(scope, "authorization") or ""
417
+ token = ""
418
+ if auth.lower().startswith("bearer "):
419
+ token = auth.split(" ", 1)[1].strip()
420
+ if not token or not self._token_valid(token):
421
+ lock = self._lockouts.record_failure(ip)
422
+ if lock is not None and lock > 0:
423
+ await self._reject(
424
+ send,
425
+ status=429,
426
+ detail="Too Many Requests (auth lockout)",
427
+ headers=[(b"retry-after", str(int(lock)).encode("utf-8"))],
428
+ )
429
+ return
430
+ await self._reject(
431
+ send,
432
+ status=401,
433
+ detail="Unauthorized",
434
+ headers=[(b"www-authenticate", b"Bearer")],
435
+ )
436
+ return
437
+ self._lockouts.record_success(ip)
438
+
439
+ # Body size limits (for mutating endpoints).
440
+ buffered_body: Optional[bytes] = None
441
+ if is_write and self._policy.max_body_bytes > 0:
442
+ cl = self._header(scope, "content-length")
443
+ if cl is not None:
444
+ try:
445
+ if int(cl) > int(self._policy.max_body_bytes):
446
+ await self._reject(send, status=413, detail="Payload Too Large")
447
+ return
448
+ except Exception:
449
+ pass
450
+ else:
451
+ # No content-length: buffer up to limit+1, then replay.
452
+ limit = int(self._policy.max_body_bytes)
453
+ chunks: list[bytes] = []
454
+ size = 0
455
+ more = True
456
+ while more:
457
+ message = await receive()
458
+ if message.get("type") != "http.request":
459
+ continue
460
+ body = message.get("body", b"") or b""
461
+ more = bool(message.get("more_body", False))
462
+ if body:
463
+ chunks.append(body)
464
+ size += len(body)
465
+ if size > limit:
466
+ await self._reject(send, status=413, detail="Payload Too Large")
467
+ return
468
+ buffered_body = b"".join(chunks)
469
+
470
+ # Concurrency limits: separate pool for SSE streams.
471
+ is_sse = path.endswith("/ledger/stream")
472
+ sem = self._sse_sema if is_sse else self._sema
473
+ acquired = await self._try_acquire(sem)
474
+ if not acquired:
475
+ await self._reject(
476
+ send,
477
+ status=429,
478
+ detail="Too Many Requests (concurrency limit)",
479
+ headers=[(b"retry-after", b"1")],
480
+ )
481
+ return
482
+
483
+ try:
484
+ if buffered_body is None:
485
+ return await self._app(scope, receive, send)
486
+
487
+ # Replay buffered body to downstream app.
488
+ sent = False
489
+
490
+ async def _replay_receive():
491
+ nonlocal sent
492
+ if sent:
493
+ return {"type": "http.request", "body": b"", "more_body": False}
494
+ sent = True
495
+ return {"type": "http.request", "body": buffered_body, "more_body": False}
496
+
497
+ return await self._app(scope, _replay_receive, send)
498
+ finally:
499
+ try:
500
+ sem.release()
501
+ except Exception:
502
+ pass
503
+
504
+
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, Optional
6
+
7
+ from .config import GatewayHostConfig
8
+ from .hosts.visualflow_host import VisualFlowGatewayHost, VisualFlowRegistry
9
+ from .runner import GatewayRunner, GatewayRunnerConfig
10
+ from .security import GatewayAuthPolicy, load_gateway_auth_policy_from_env
11
+ from .stores import GatewayStores, build_file_stores
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class GatewayService:
16
+ """Composition root: host + runner + security policy."""
17
+
18
+ config: GatewayHostConfig
19
+ stores: GatewayStores
20
+ host: Any
21
+ runner: GatewayRunner
22
+ auth_policy: GatewayAuthPolicy
23
+
24
+
25
+ _service: Optional[GatewayService] = None
26
+
27
+
28
+ def get_gateway_service() -> GatewayService:
29
+ global _service
30
+ if _service is None:
31
+ _service = create_default_gateway_service()
32
+ return _service
33
+
34
+
35
+ def create_default_gateway_service() -> GatewayService:
36
+ cfg = GatewayHostConfig.from_env()
37
+ stores = build_file_stores(base_dir=cfg.data_dir)
38
+
39
+ # Workflow source:
40
+ # - bundle (default): `.flow` bundles with VisualFlow JSON (compiled via AbstractRuntime; no AbstractFlow import)
41
+ # - visualflow (optional): load VisualFlow JSON files directly from a directory (host wiring currently uses AbstractFlow extras)
42
+ source = str(os.getenv("ABSTRACTGATEWAY_WORKFLOW_SOURCE", "bundle") or "bundle").strip().lower()
43
+ if source == "bundle":
44
+ from .hosts.bundle_host import WorkflowBundleGatewayHost
45
+
46
+ host = WorkflowBundleGatewayHost.load_from_dir(
47
+ bundles_dir=cfg.flows_dir,
48
+ run_store=stores.run_store,
49
+ ledger_store=stores.ledger_store,
50
+ artifact_store=stores.artifact_store,
51
+ )
52
+ elif source == "visualflow":
53
+ flows = VisualFlowRegistry(flows_dir=cfg.flows_dir).load()
54
+ host = VisualFlowGatewayHost(
55
+ flows_dir=cfg.flows_dir,
56
+ flows=flows,
57
+ run_store=stores.run_store,
58
+ ledger_store=stores.ledger_store,
59
+ artifact_store=stores.artifact_store,
60
+ )
61
+ else:
62
+ raise RuntimeError(f"Unsupported workflow source: {source}. Supported: bundle|visualflow")
63
+
64
+ runner_cfg = GatewayRunnerConfig(
65
+ poll_interval_s=float(cfg.poll_interval_s),
66
+ command_batch_limit=int(cfg.command_batch_limit),
67
+ tick_max_steps=int(cfg.tick_max_steps),
68
+ tick_workers=int(cfg.tick_workers),
69
+ run_scan_limit=int(cfg.run_scan_limit),
70
+ )
71
+ runner = GatewayRunner(base_dir=stores.base_dir, host=host, config=runner_cfg, enable=bool(cfg.runner_enabled))
72
+
73
+ policy = load_gateway_auth_policy_from_env()
74
+ return GatewayService(config=cfg, stores=stores, host=host, runner=runner, auth_policy=policy)
75
+
76
+
77
+ def start_gateway_runner() -> None:
78
+ svc = get_gateway_service()
79
+ svc.runner.start()
80
+
81
+
82
+ def stop_gateway_runner() -> None:
83
+ global _service
84
+ if _service is None:
85
+ return
86
+ try:
87
+ _service.runner.stop()
88
+ finally:
89
+ _service = None
90
+
91
+
92
+ def run_summary(run: Any) -> Dict[str, Any]:
93
+ """HTTP-safe run summary (do not return full run.vars)."""
94
+
95
+ waiting = getattr(run, "waiting", None)
96
+ status = getattr(getattr(run, "status", None), "value", None) or str(getattr(run, "status", "unknown"))
97
+ out: Dict[str, Any] = {
98
+ "run_id": getattr(run, "run_id", ""),
99
+ "workflow_id": getattr(run, "workflow_id", None),
100
+ "status": status,
101
+ "current_node": getattr(run, "current_node", None),
102
+ "created_at": getattr(run, "created_at", None),
103
+ "updated_at": getattr(run, "updated_at", None),
104
+ "parent_run_id": getattr(run, "parent_run_id", None),
105
+ "error": getattr(run, "error", None),
106
+ # Best-effort pause metadata. We intentionally do not return full run.vars over HTTP.
107
+ "paused": False,
108
+ "pause_reason": None,
109
+ "paused_at": None,
110
+ "resumed_at": None,
111
+ "waiting": None,
112
+ }
113
+ try:
114
+ vars_obj = getattr(run, "vars", None)
115
+ runtime_ns = vars_obj.get("_runtime") if isinstance(vars_obj, dict) else None
116
+ control = runtime_ns.get("control") if isinstance(runtime_ns, dict) else None
117
+ if isinstance(control, dict):
118
+ out["paused"] = bool(control.get("paused") is True)
119
+ out["pause_reason"] = control.get("pause_reason")
120
+ out["paused_at"] = control.get("paused_at")
121
+ out["resumed_at"] = control.get("resumed_at")
122
+ except Exception:
123
+ pass
124
+ if waiting is not None:
125
+ out["waiting"] = {
126
+ "reason": getattr(getattr(waiting, "reason", None), "value", None) or str(getattr(waiting, "reason", "")),
127
+ "wait_key": getattr(waiting, "wait_key", None),
128
+ "prompt": getattr(waiting, "prompt", None),
129
+ "choices": getattr(waiting, "choices", None),
130
+ "allow_free_text": getattr(waiting, "allow_free_text", None),
131
+ "details": getattr(waiting, "details", None),
132
+ }
133
+ return out
134
+
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class GatewayStores:
10
+ """Concrete durability stores used by a gateway host."""
11
+
12
+ base_dir: Path
13
+ run_store: Any
14
+ ledger_store: Any
15
+ artifact_store: Any
16
+
17
+
18
+ def build_file_stores(*, base_dir: Path) -> GatewayStores:
19
+ """Create file-backed Run/Ledger/Artifact stores under base_dir.
20
+
21
+ Contract: base_dir is owned by the gateway host process (durable control plane).
22
+ """
23
+
24
+ from abstractruntime import FileArtifactStore, JsonFileRunStore, JsonlLedgerStore, ObservableLedgerStore
25
+
26
+ base = Path(base_dir).expanduser().resolve()
27
+ base.mkdir(parents=True, exist_ok=True)
28
+
29
+ run_store = JsonFileRunStore(base)
30
+ ledger_store = ObservableLedgerStore(JsonlLedgerStore(base))
31
+ artifact_store = FileArtifactStore(base)
32
+ return GatewayStores(base_dir=base, run_store=run_store, ledger_store=ledger_store, artifact_store=artifact_store)
33
+
34
+