alter-runtime 0.3.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.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,506 @@
1
+ """EbpfSubscriber - wires the eBPF kernel attestation loader into the daemon (Wave 5b).
2
+
3
+ The Rust ``alter-ebpf`` binary (subcrate ``packages/alter-ebpf``) is the
4
+ userspace half of a BPF LSM program that observes ``bprm_check_security``
5
+ and emits one ``alter1``-shaped JSON envelope per line to stdout for every
6
+ exec on a uid the operator opted in to. Patent M (AMCZ-2615626261, FILED
7
+ 7 Apr 2026) covers this kernel-adjacent attestation surface.
8
+
9
+ This subscriber is the bridge between that binary and the in-process
10
+ :class:`EventBus`. It:
11
+
12
+ 1. Resolves the binary path (``ALTER_EBPF_BIN`` env var, then
13
+ :func:`shutil.which`, then :data:`DEFAULT_BINARY_NAMES`).
14
+ 2. Resolves the target uid (``ALTER_EBPF_TARGET_UID`` env var, then the
15
+ effective uid of the daemon process). The privacy filter inside the
16
+ BPF program drops every event whose uid does not match this value, so
17
+ the daemon never sees another user's exec stream even if it were
18
+ started as root.
19
+ 3. Spawns the binary as an asyncio subprocess (``alter-ebpf load
20
+ --target-uid <uid>``).
21
+ 4. Reads stdout line-by-line, parses each line as the ``alter1`` envelope
22
+ the Rust loader produces, and publishes:
23
+
24
+ * :data:`TOPIC_KERNEL_EXEC` - the ``payload`` dict from the envelope,
25
+ so subscribers that only care about kernel exec events can listen
26
+ on a specific topic without filtering.
27
+ * :data:`TOPIC_LOCAL_SIGNAL` - the full envelope, mirroring the
28
+ convention established by ``git_watcher`` and other local adapters.
29
+ The egress producer (Wave 6) will relay these to the DO.
30
+ 5. Treats stderr as plain log output and reroutes it through the
31
+ subscriber logger at INFO level. The Rust binary writes
32
+ ``tracing-subscriber`` output to stderr; we don't try to parse it.
33
+ 6. On any unclean exit (non-zero status, transient I/O error,
34
+ subprocess crash) backs off and restarts the loader. The supervisor
35
+ provides the outer safety net for truly unexpected failures, but the
36
+ inner backoff loop is what survives a kernel verifier rejecting a
37
+ newly hot-reloaded program.
38
+
39
+ The subscriber gracefully **disables itself** if:
40
+
41
+ * the ``alter-ebpf`` binary is not on PATH (and ``ALTER_EBPF_BIN`` is not
42
+ set);
43
+ * the binary is found but the platform is not Linux;
44
+ * the loader exits non-zero on its first attach attempt with the same
45
+ error twice in a row (typically: kernel lacks BPF LSM in the active
46
+ stack - there is no point retrying).
47
+
48
+ Disabling logs once at WARNING and the :meth:`run` task exits cleanly so
49
+ the supervisor does not restart it. This is the right behaviour for an
50
+ opt-in, kernel-version-sensitive surface - we should not flood the
51
+ journal on hosts that simply don't have BPF LSM enabled.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import asyncio
57
+ import json
58
+ import logging
59
+ import os
60
+ import shutil
61
+ from dataclasses import dataclass, field
62
+ from typing import TYPE_CHECKING, Any, Callable
63
+
64
+ from alter_runtime.daemon import Component
65
+
66
+ if TYPE_CHECKING:
67
+ from collections.abc import Awaitable
68
+
69
+ from alter_runtime.config import DaemonConfig
70
+ from alter_runtime.subscribers.bus import EventBus
71
+
72
+ __all__ = [
73
+ "DEFAULT_BINARY_NAMES",
74
+ "EbpfSubscriber",
75
+ "TOPIC_KERNEL_EXEC",
76
+ "TOPIC_LOCAL_SIGNAL",
77
+ ]
78
+
79
+ logger = logging.getLogger("alter_runtime.subscribers.ebpf")
80
+
81
+ #: Topic published with the parsed payload dict from each kernel exec event.
82
+ TOPIC_KERNEL_EXEC: str = "kernel.attest.exec"
83
+
84
+ #: Topic published with the full ``alter1`` envelope, matching the convention
85
+ #: used by other local adapters that emit ambient signals destined for DO
86
+ #: relay (Wave 6).
87
+ TOPIC_LOCAL_SIGNAL: str = "local.signal"
88
+
89
+ #: Names that resolve via :func:`shutil.which` if no explicit binary path
90
+ #: is configured. The first one to resolve wins.
91
+ DEFAULT_BINARY_NAMES: tuple[str, ...] = ("alter-ebpf",)
92
+
93
+ #: Initial back-off in seconds after a transient subprocess failure.
94
+ BASE_BACKOFF_SECONDS: float = 1.0
95
+
96
+ #: Upper bound on exponential back-off between subprocess restarts.
97
+ MAX_BACKOFF_SECONDS: float = 60.0
98
+
99
+ #: Maximum line length we will buffer from stdout. The Rust loader emits one
100
+ #: JSON object per line; the largest legitimate line is bounded by the
101
+ #: ``filename`` field (256 bytes) plus envelope overhead. We cap at 16 KiB
102
+ #: to absorb future fields without giving an attacker a way to OOM the
103
+ #: daemon if a malicious binary on PATH ever masqueraded as alter-ebpf.
104
+ MAX_LINE_BYTES: int = 16 * 1024
105
+
106
+ #: A factory that returns a started ``asyncio.subprocess.Process``. Tests
107
+ #: pass a fake here to avoid actually spawning the Rust binary.
108
+ ProcessFactory = Callable[[list[str]], "Awaitable[asyncio.subprocess.Process]"]
109
+
110
+
111
+ @dataclass
112
+ class _SubscriberState:
113
+ """Internal book-keeping (exposed via :attr:`EbpfSubscriber.state` for tests)."""
114
+
115
+ spawn_count: int = 0
116
+ line_count: int = 0
117
+ parse_error_count: int = 0
118
+ publish_count: int = 0
119
+ backoff: float = BASE_BACKOFF_SECONDS
120
+ disabled: bool = False
121
+ history: list[str] = field(default_factory=list)
122
+
123
+
124
+ class EbpfSubscriber(Component):
125
+ """Spawns the eBPF loader and republishes its event stream onto the bus.
126
+
127
+ Parameters
128
+ ----------
129
+ config:
130
+ Loaded :class:`DaemonConfig`. Currently unused but accepted so the
131
+ subscriber matches the calling convention of every other Component
132
+ in the package and so future config knobs (poll interval, ringbuf
133
+ sizing) can be added without breaking callers.
134
+ bus:
135
+ The shared :class:`EventBus` instance.
136
+ binary_path:
137
+ Optional explicit path to the ``alter-ebpf`` binary. If ``None``,
138
+ the subscriber consults ``ALTER_EBPF_BIN`` and then
139
+ :func:`shutil.which`. The Path equivalent works too.
140
+ target_uid:
141
+ Optional uid to observe. Defaults to ``ALTER_EBPF_TARGET_UID`` then
142
+ the daemon's effective uid.
143
+ process_factory:
144
+ Optional factory that returns an ``asyncio.subprocess.Process``.
145
+ Tests inject a fake to drive the subscriber without exec-ing the
146
+ real Rust binary. Production callers leave this ``None`` and the
147
+ subscriber uses :func:`asyncio.create_subprocess_exec`.
148
+ extra_args:
149
+ Optional extra CLI arguments appended after ``load --target-uid
150
+ <uid>``. Useful for future flags (e.g. ``--ringbuf-pages``) and
151
+ for tests that want to confirm pass-through.
152
+ """
153
+
154
+ name = "ebpf"
155
+
156
+ def __init__(
157
+ self,
158
+ config: DaemonConfig,
159
+ bus: EventBus,
160
+ *,
161
+ binary_path: str | os.PathLike[str] | None = None,
162
+ target_uid: int | None = None,
163
+ process_factory: ProcessFactory | None = None,
164
+ extra_args: list[str] | None = None,
165
+ ) -> None:
166
+ self._config = config
167
+ self._bus = bus
168
+ self._binary_path = self._resolve_binary(binary_path)
169
+ self._target_uid = self._resolve_target_uid(target_uid)
170
+ self._process_factory: ProcessFactory = (
171
+ process_factory if process_factory is not None else _default_process_factory
172
+ )
173
+ self._extra_args = list(extra_args or [])
174
+ self._stop_event = asyncio.Event()
175
+ self._proc: asyncio.subprocess.Process | None = None
176
+ self._state = _SubscriberState()
177
+
178
+ # ------------------------------------------------------------------
179
+ # Component lifecycle
180
+ # ------------------------------------------------------------------
181
+
182
+ async def run(self) -> None:
183
+ """Long-lived loop: spawn → drain stdout → restart on failure."""
184
+ if self._binary_path is None:
185
+ logger.warning(
186
+ "ebpf subscriber disabled - alter-ebpf binary not found "
187
+ "(set ALTER_EBPF_BIN or install via packages/alter-ebpf). "
188
+ "Kernel attestation will not be available on this host."
189
+ )
190
+ self._state.disabled = True
191
+ return
192
+
193
+ argv = [
194
+ str(self._binary_path),
195
+ "load",
196
+ "--target-uid",
197
+ str(self._target_uid),
198
+ *self._extra_args,
199
+ ]
200
+ logger.info(
201
+ "ebpf starting binary=%s target_uid=%d",
202
+ self._binary_path,
203
+ self._target_uid,
204
+ )
205
+
206
+ consecutive_immediate_failures = 0
207
+ while not self._stop_event.is_set():
208
+ try:
209
+ exit_code = await self._run_one_session(argv)
210
+ except asyncio.CancelledError:
211
+ raise
212
+ except FileNotFoundError as exc:
213
+ # The binary disappeared between resolution and spawn -
214
+ # treat exactly as the resolution-failure case above.
215
+ logger.warning(
216
+ "ebpf binary vanished from %s (%s) - disabling",
217
+ self._binary_path,
218
+ exc,
219
+ )
220
+ self._state.disabled = True
221
+ return
222
+ except Exception as exc:
223
+ logger.exception("ebpf unexpected error spawning loader: %s", exc)
224
+ exit_code = -1
225
+
226
+ if self._stop_event.is_set():
227
+ break
228
+
229
+ # If the loader keeps exiting immediately (within < base backoff)
230
+ # twice in a row, the kernel is almost certainly missing BPF LSM
231
+ # support and we should disable rather than spinning the journal.
232
+ if exit_code != 0:
233
+ consecutive_immediate_failures += 1
234
+ if consecutive_immediate_failures >= 2:
235
+ logger.warning(
236
+ "ebpf loader exited non-zero twice in a row "
237
+ "(last exit=%d) - disabling. Common causes: bpf LSM "
238
+ "not in /sys/kernel/security/lsm; daemon lacks "
239
+ "CAP_BPF + CAP_PERFMON + CAP_SYS_ADMIN; kernel BTF "
240
+ "missing.",
241
+ exit_code,
242
+ )
243
+ self._state.disabled = True
244
+ return
245
+ else:
246
+ consecutive_immediate_failures = 0
247
+
248
+ await self._backoff_then_retry()
249
+
250
+ logger.info("ebpf stopped binary=%s", self._binary_path)
251
+
252
+ async def stop(self) -> None:
253
+ """Cooperative shutdown - kill the subprocess and release the loop."""
254
+ self._stop_event.set()
255
+ proc = self._proc
256
+ if proc is not None and proc.returncode is None:
257
+ try:
258
+ proc.terminate()
259
+ except ProcessLookupError:
260
+ pass
261
+
262
+ # ------------------------------------------------------------------
263
+ # Subprocess session
264
+ # ------------------------------------------------------------------
265
+
266
+ async def _run_one_session(self, argv: list[str]) -> int:
267
+ """Spawn one loader instance and drain it until it exits.
268
+
269
+ Returns the subprocess exit code (or ``-1`` for an internal error).
270
+ """
271
+ self._state.spawn_count += 1
272
+ self._state.history.append(f"spawn:{self._state.spawn_count}")
273
+ proc = await self._process_factory(argv)
274
+ self._proc = proc
275
+ try:
276
+ stdout_task = asyncio.create_task(self._drain_stdout(proc))
277
+ stderr_task = asyncio.create_task(self._drain_stderr(proc))
278
+ stop_waiter = asyncio.create_task(self._stop_event.wait())
279
+
280
+ done, pending = await asyncio.wait(
281
+ {stdout_task, stop_waiter},
282
+ return_when=asyncio.FIRST_COMPLETED,
283
+ )
284
+
285
+ if stop_waiter in done:
286
+ # Caller asked us to stop. Terminate the loader and let
287
+ # the drain tasks finish naturally on EOF.
288
+ if proc.returncode is None:
289
+ try:
290
+ proc.terminate()
291
+ except ProcessLookupError:
292
+ pass
293
+ else:
294
+ stop_waiter.cancel()
295
+
296
+ # Always finish draining whatever's still buffered. Bound the
297
+ # wait so a wedged subprocess can't hang shutdown indefinitely.
298
+ for task in (stdout_task, stderr_task):
299
+ if not task.done():
300
+ try:
301
+ await asyncio.wait_for(task, timeout=5.0)
302
+ except (TimeoutError, asyncio.TimeoutError):
303
+ task.cancel()
304
+
305
+ try:
306
+ exit_code = await asyncio.wait_for(proc.wait(), timeout=5.0)
307
+ except (TimeoutError, asyncio.TimeoutError):
308
+ logger.warning("ebpf loader did not exit after terminate; killing")
309
+ try:
310
+ proc.kill()
311
+ except ProcessLookupError:
312
+ pass
313
+ exit_code = await proc.wait()
314
+
315
+ logger.info(
316
+ "ebpf loader session ended exit=%d lines=%d publishes=%d parse_errors=%d",
317
+ exit_code,
318
+ self._state.line_count,
319
+ self._state.publish_count,
320
+ self._state.parse_error_count,
321
+ )
322
+ return exit_code
323
+ finally:
324
+ self._proc = None
325
+
326
+ # ------------------------------------------------------------------
327
+ # Drain helpers
328
+ # ------------------------------------------------------------------
329
+
330
+ async def _drain_stdout(self, proc: asyncio.subprocess.Process) -> None:
331
+ """Read newline-delimited JSON envelopes and publish to the bus."""
332
+ stdout = proc.stdout
333
+ if stdout is None:
334
+ logger.warning("ebpf process has no stdout pipe")
335
+ return
336
+ while True:
337
+ try:
338
+ line = await stdout.readline()
339
+ except asyncio.LimitOverrunError as exc:
340
+ # asyncio.StreamReader.readline raises this if a line
341
+ # exceeds the buffer limit. Discard up to the next newline
342
+ # and keep going.
343
+ logger.warning(
344
+ "ebpf line longer than buffer (%d bytes consumed) - discarding",
345
+ exc.consumed,
346
+ )
347
+ await stdout.read(exc.consumed)
348
+ self._state.parse_error_count += 1
349
+ continue
350
+ if not line:
351
+ return # EOF
352
+ self._state.line_count += 1
353
+ await self._handle_line(line)
354
+
355
+ async def _drain_stderr(self, proc: asyncio.subprocess.Process) -> None:
356
+ """Reroute the loader's stderr through our logger at INFO level."""
357
+ stderr = proc.stderr
358
+ if stderr is None:
359
+ return
360
+ while True:
361
+ line = await stderr.readline()
362
+ if not line:
363
+ return
364
+ text = line.decode("utf-8", errors="replace").rstrip()
365
+ if text:
366
+ logger.info("ebpf-loader: %s", text)
367
+
368
+ async def _handle_line(self, raw: bytes) -> None:
369
+ """Parse one stdout line and publish on the bus.
370
+
371
+ Bad JSON or unexpected envelope shapes are counted and dropped -
372
+ a single malformed line must never crash the dispatcher.
373
+ """
374
+ if len(raw) > MAX_LINE_BYTES:
375
+ logger.warning("ebpf line %d bytes > MAX_LINE_BYTES - dropping", len(raw))
376
+ self._state.parse_error_count += 1
377
+ return
378
+ try:
379
+ envelope: Any = json.loads(raw)
380
+ except json.JSONDecodeError as exc:
381
+ logger.debug("ebpf line not JSON: %s (%r)", exc, raw[:120])
382
+ self._state.parse_error_count += 1
383
+ return
384
+ if not isinstance(envelope, dict):
385
+ logger.debug("ebpf line not a JSON object: %r", raw[:120])
386
+ self._state.parse_error_count += 1
387
+ return
388
+ if envelope.get("v") != "alter1":
389
+ logger.debug("ebpf line missing alter1 envelope: %r", envelope.get("v"))
390
+ self._state.parse_error_count += 1
391
+ return
392
+
393
+ kind = envelope.get("kind")
394
+ payload = envelope.get("payload")
395
+ if not isinstance(kind, str) or not isinstance(payload, dict):
396
+ logger.debug("ebpf envelope missing kind/payload: %r", envelope)
397
+ self._state.parse_error_count += 1
398
+ return
399
+
400
+ # Specific topic for kernel-exec consumers.
401
+ if kind == "kernel.attest.exec":
402
+ await self._bus.publish(TOPIC_KERNEL_EXEC, payload)
403
+
404
+ # Generic local-signal topic for the egress relay (Wave 6) and any
405
+ # other subscriber that wants every ambient signal regardless of
406
+ # source. Mirrors what git_watcher publishes.
407
+ await self._bus.publish(TOPIC_LOCAL_SIGNAL, envelope)
408
+ self._state.publish_count += 1
409
+
410
+ # ------------------------------------------------------------------
411
+ # Resolution helpers
412
+ # ------------------------------------------------------------------
413
+
414
+ def _resolve_binary(self, explicit: str | os.PathLike[str] | None) -> str | None:
415
+ """Find the alter-ebpf binary, returning ``None`` if unavailable."""
416
+ if explicit is not None:
417
+ path = os.fspath(explicit)
418
+ if os.path.isfile(path) and os.access(path, os.X_OK):
419
+ return path
420
+ logger.warning(
421
+ "ebpf binary path %r is not an executable file - falling back to PATH",
422
+ path,
423
+ )
424
+ env = os.environ.get("ALTER_EBPF_BIN")
425
+ if env:
426
+ if os.path.isfile(env) and os.access(env, os.X_OK):
427
+ return env
428
+ logger.warning(
429
+ "ALTER_EBPF_BIN=%r is not an executable file - falling back to PATH",
430
+ env,
431
+ )
432
+ for name in DEFAULT_BINARY_NAMES:
433
+ resolved = shutil.which(name)
434
+ if resolved:
435
+ return resolved
436
+ return None
437
+
438
+ def _resolve_target_uid(self, explicit: int | None) -> int:
439
+ """Decide which uid to observe."""
440
+ if explicit is not None:
441
+ return explicit
442
+ env = os.environ.get("ALTER_EBPF_TARGET_UID")
443
+ if env:
444
+ try:
445
+ return int(env)
446
+ except ValueError:
447
+ logger.warning(
448
+ "ALTER_EBPF_TARGET_UID=%r is not an integer - using effective uid",
449
+ env,
450
+ )
451
+ try:
452
+ return os.geteuid()
453
+ except AttributeError:
454
+ # Non-POSIX (Windows) - uid concept doesn't apply but the
455
+ # subscriber is Linux-only in practice. Return 0 as a placeholder
456
+ # so we still spawn the binary in tests on non-POSIX hosts.
457
+ return 0
458
+
459
+ # ------------------------------------------------------------------
460
+ # Backoff
461
+ # ------------------------------------------------------------------
462
+
463
+ async def _backoff_then_retry(self) -> None:
464
+ """Sleep for the current backoff, then double it for next time."""
465
+ delay = self._state.backoff
466
+ self._state.backoff = min(delay * 2, MAX_BACKOFF_SECONDS)
467
+ logger.info("ebpf restarting loader in %.1fs", delay)
468
+ try:
469
+ await asyncio.wait_for(self._stop_event.wait(), timeout=delay)
470
+ except (TimeoutError, asyncio.TimeoutError):
471
+ return
472
+
473
+ # ------------------------------------------------------------------
474
+ # Test introspection
475
+ # ------------------------------------------------------------------
476
+
477
+ @property
478
+ def state(self) -> _SubscriberState:
479
+ """Internal state snapshot (used by tests)."""
480
+ return self._state
481
+
482
+ @property
483
+ def binary_path(self) -> str | None:
484
+ """The resolved binary path, or ``None`` if the subscriber is disabled."""
485
+ return self._binary_path
486
+
487
+
488
+ # ---------------------------------------------------------------------------
489
+ # Default process factory
490
+ # ---------------------------------------------------------------------------
491
+
492
+
493
+ async def _default_process_factory(argv: list[str]) -> asyncio.subprocess.Process:
494
+ """Spawn the loader via :func:`asyncio.create_subprocess_exec`."""
495
+ return await asyncio.create_subprocess_exec(
496
+ *argv,
497
+ stdout=asyncio.subprocess.PIPE,
498
+ stderr=asyncio.subprocess.PIPE,
499
+ # The loader must not inherit our stdin - it doesn't read it and
500
+ # connecting to a tty can cause confusing behaviour under systemd.
501
+ stdin=asyncio.subprocess.DEVNULL,
502
+ # Run in its own process group so a Ctrl-C delivered to the daemon
503
+ # supervisor's pgid doesn't double-signal the loader before our
504
+ # cooperative terminate() lands.
505
+ start_new_session=True,
506
+ )