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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!--
|
|
3
|
+
alter-runtime — ~Alter Identity Runtime (launchd user agent template)
|
|
4
|
+
|
|
5
|
+
Install path: ~/Library/LaunchAgents/com.alter.runtime.plist
|
|
6
|
+
|
|
7
|
+
Placeholders substituted by alter_runtime.service_install.render_launchd_plist():
|
|
8
|
+
@ALTER_RUNTIME_BIN@ — absolute path to the `alter-runtime` entry point
|
|
9
|
+
@ALTER_RUNTIME_LOG_DIR@ — directory for stdout/stderr log files
|
|
10
|
+
@ALTER_RUNTIME_HOME@ — value of $HOME at install time
|
|
11
|
+
|
|
12
|
+
After install, load with:
|
|
13
|
+
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.alter.runtime.plist
|
|
14
|
+
launchctl kickstart -k gui/$(id -u)/com.alter.runtime
|
|
15
|
+
|
|
16
|
+
Unload with:
|
|
17
|
+
launchctl bootout gui/$(id -u)/com.alter.runtime
|
|
18
|
+
-->
|
|
19
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
20
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
21
|
+
<plist version="1.0">
|
|
22
|
+
<dict>
|
|
23
|
+
<key>Label</key>
|
|
24
|
+
<string>com.alter.runtime</string>
|
|
25
|
+
|
|
26
|
+
<key>ProgramArguments</key>
|
|
27
|
+
<array>
|
|
28
|
+
<string>@ALTER_RUNTIME_BIN@</string>
|
|
29
|
+
<string>daemon</string>
|
|
30
|
+
</array>
|
|
31
|
+
|
|
32
|
+
<!-- Start at login and keep running; restart on crash with a 5-second throttle. -->
|
|
33
|
+
<key>RunAtLoad</key>
|
|
34
|
+
<true/>
|
|
35
|
+
<key>KeepAlive</key>
|
|
36
|
+
<dict>
|
|
37
|
+
<key>SuccessfulExit</key>
|
|
38
|
+
<false/>
|
|
39
|
+
<key>Crashed</key>
|
|
40
|
+
<true/>
|
|
41
|
+
</dict>
|
|
42
|
+
<key>ThrottleInterval</key>
|
|
43
|
+
<integer>5</integer>
|
|
44
|
+
|
|
45
|
+
<!-- Tight process-creation mask so sockets / state files land at 0o600. -->
|
|
46
|
+
<key>Umask</key>
|
|
47
|
+
<integer>63</integer>
|
|
48
|
+
|
|
49
|
+
<!-- Logs land under ~/Library/Logs/alter-runtime/ so macOS Console.app picks them up. -->
|
|
50
|
+
<key>StandardOutPath</key>
|
|
51
|
+
<string>@ALTER_RUNTIME_LOG_DIR@/alter-runtime.log</string>
|
|
52
|
+
<key>StandardErrorPath</key>
|
|
53
|
+
<string>@ALTER_RUNTIME_LOG_DIR@/alter-runtime.err.log</string>
|
|
54
|
+
|
|
55
|
+
<!-- Environment -->
|
|
56
|
+
<key>EnvironmentVariables</key>
|
|
57
|
+
<dict>
|
|
58
|
+
<key>HOME</key>
|
|
59
|
+
<string>@ALTER_RUNTIME_HOME@</string>
|
|
60
|
+
</dict>
|
|
61
|
+
|
|
62
|
+
<!-- Soft process limits — mirror the systemd unit. ProcessType=Background
|
|
63
|
+
tells launchd to apply the OS X timer / IO / scheduling defaults for
|
|
64
|
+
a background daemon (less CPU + IO priority, eligible for App Nap),
|
|
65
|
+
and LowPriorityIO further drops disk-IO priority so the daemon never
|
|
66
|
+
starves a foreground IDE / build that the operator is actively using.
|
|
67
|
+
|
|
68
|
+
Pentest 2026-04-26 (MEDIUM) follow-up: ship a sandbox-exec profile
|
|
69
|
+
next to this template that constrains filesystem writes to
|
|
70
|
+
~/Library/Application Support/alter, ~/Library/Caches/alter and
|
|
71
|
+
~/Library/Logs/alter-runtime, denies all network listening sockets
|
|
72
|
+
except the Unix socket under Application Support, and uses
|
|
73
|
+
``allow-with-prompt`` for outbound HTTPS to alter-api.fly.dev only.
|
|
74
|
+
Tracked at alter-runtime#sandbox-profile (TODO; out of scope for
|
|
75
|
+
this PR — the plist itself is the security floor pre-sandbox). -->
|
|
76
|
+
<key>ProcessType</key>
|
|
77
|
+
<string>Background</string>
|
|
78
|
+
<key>LowPriorityIO</key>
|
|
79
|
+
<true/>
|
|
80
|
+
|
|
81
|
+
<!-- Nice-to-have for GUI sessions: the LaunchAgent inherits the user's
|
|
82
|
+
Aqua session so the Unix socket at ~/Library/Application Support/alter
|
|
83
|
+
is writable without extra entitlements. -->
|
|
84
|
+
<key>LimitLoadToSessionType</key>
|
|
85
|
+
<array>
|
|
86
|
+
<string>Aqua</string>
|
|
87
|
+
<string>Background</string>
|
|
88
|
+
</array>
|
|
89
|
+
</dict>
|
|
90
|
+
</plist>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#
|
|
2
|
+
# alter-runtime — ~Alter Identity Runtime (systemd user unit template)
|
|
3
|
+
#
|
|
4
|
+
# Install path: ~/.config/systemd/user/alter-runtime.service
|
|
5
|
+
#
|
|
6
|
+
# Placeholders substituted by alter_runtime.service_install.render_systemd_unit():
|
|
7
|
+
# @ALTER_RUNTIME_BIN@ — absolute path to the `alter-runtime` entry point
|
|
8
|
+
# @ALTER_RUNTIME_HOME@ — value of $HOME at install time (for Environment=)
|
|
9
|
+
#
|
|
10
|
+
# After install, enable with:
|
|
11
|
+
# systemctl --user daemon-reload
|
|
12
|
+
# systemctl --user enable --now alter-runtime.service
|
|
13
|
+
#
|
|
14
|
+
# Logs: journalctl --user -u alter-runtime -f
|
|
15
|
+
#
|
|
16
|
+
|
|
17
|
+
[Unit]
|
|
18
|
+
Description=~Alter Identity Runtime — local sovereign daemon
|
|
19
|
+
Documentation=https://github.com/true-alter/Alter/blob/main/packages/alter-runtime/README.md
|
|
20
|
+
# We do not require network-online.target because the daemon tolerates
|
|
21
|
+
# offline starts — it degrades to its local cache and retries the DO
|
|
22
|
+
# connection with exponential backoff (D-RT9).
|
|
23
|
+
After=default.target
|
|
24
|
+
# Cap restart storms during a bad deploy. StartLimit* keys live on [Unit]
|
|
25
|
+
# (not [Service]) per systemd.unit(5).
|
|
26
|
+
StartLimitIntervalSec=60
|
|
27
|
+
StartLimitBurst=5
|
|
28
|
+
|
|
29
|
+
[Service]
|
|
30
|
+
Type=exec
|
|
31
|
+
ExecStart=@ALTER_RUNTIME_BIN@ daemon
|
|
32
|
+
Restart=on-failure
|
|
33
|
+
RestartSec=5s
|
|
34
|
+
|
|
35
|
+
# Environment
|
|
36
|
+
Environment=HOME=@ALTER_RUNTIME_HOME@
|
|
37
|
+
# XDG_RUNTIME_DIR is set by pam_systemd on interactive logins; for headless
|
|
38
|
+
# sessions we let the daemon fall back to the per-uid /tmp path via
|
|
39
|
+
# alter_runtime.config.unix_socket_path().
|
|
40
|
+
|
|
41
|
+
# Keep stdout/stderr on the journal so `journalctl --user -u alter-runtime`
|
|
42
|
+
# gives the operator immediate visibility.
|
|
43
|
+
StandardOutput=journal
|
|
44
|
+
StandardError=journal
|
|
45
|
+
SyslogIdentifier=alter-runtime
|
|
46
|
+
|
|
47
|
+
# Process hygiene — run as the invoking user (implicit for user units),
|
|
48
|
+
# with a tight umask so newly created socket / state files stay 0o600.
|
|
49
|
+
UMask=0077
|
|
50
|
+
|
|
51
|
+
# Resource limits — the daemon holds one httpx connection + small state
|
|
52
|
+
# caches. Everything else is defensive.
|
|
53
|
+
LimitNOFILE=1024
|
|
54
|
+
MemoryMax=256M
|
|
55
|
+
TasksMax=64
|
|
56
|
+
|
|
57
|
+
# Hardening (works across systemd ≥ 240, which every supported distro ships)
|
|
58
|
+
NoNewPrivileges=true
|
|
59
|
+
PrivateTmp=true
|
|
60
|
+
ProtectSystem=strict
|
|
61
|
+
ProtectHome=read-only
|
|
62
|
+
ReadWritePaths=%h/.config/alter %h/.cache/alter %h/.local/state/alter %h/.local/state/alter-runtime %h/.local/share/alter-runtime %h/.local/share/org-alter %t
|
|
63
|
+
ProtectKernelTunables=true
|
|
64
|
+
ProtectKernelModules=true
|
|
65
|
+
ProtectKernelLogs=true
|
|
66
|
+
ProtectControlGroups=true
|
|
67
|
+
RestrictSUIDSGID=true
|
|
68
|
+
RestrictNamespaces=true
|
|
69
|
+
LockPersonality=true
|
|
70
|
+
SystemCallFilter=@system-service
|
|
71
|
+
SystemCallErrorNumber=EPERM
|
|
72
|
+
|
|
73
|
+
[Install]
|
|
74
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# alter-runtime — Cloudflare Access service-token environment drop-in.
|
|
2
|
+
#
|
|
3
|
+
# Install path: ~/.config/systemd/user/alter-runtime.service.d/cf-access-env.conf
|
|
4
|
+
#
|
|
5
|
+
# Loads the host's Cloudflare Access service-token credentials from
|
|
6
|
+
# ~/.config/alter/cf-access.env so the daemon's httpx clients can inject
|
|
7
|
+
# CF-Access-Client-Id + CF-Access-Client-Secret headers on every request
|
|
8
|
+
# to mcp.truealter.com. Without these headers CF Access intercepts at the
|
|
9
|
+
# edge with a 302 redirect to the truealteraccess.com login flow and the
|
|
10
|
+
# daemon's session-JWT bearer token is never seen by the origin.
|
|
11
|
+
#
|
|
12
|
+
# The leading "-" on the EnvironmentFile= line makes the load non-fatal
|
|
13
|
+
# when the file is absent — dev hosts, CI runners, and freshly installed
|
|
14
|
+
# systems without a CF Access service token configured all still start
|
|
15
|
+
# the daemon cleanly. The daemon's HTTP client treats absent env-vars as
|
|
16
|
+
# "no CF Access auth" and returns an empty header dict from
|
|
17
|
+
# alter_runtime.http_auth.cf_access_default_headers().
|
|
18
|
+
#
|
|
19
|
+
# Sister doctrine: D-SUBSTRATE-UNIFIED-1 §2.3 (the CF Access fork),
|
|
20
|
+
# feedback-cf-access-secrets-out-of-mcp-json (envfile is the single
|
|
21
|
+
# source of truth for CF Access service-token credentials).
|
|
22
|
+
#
|
|
23
|
+
# Option B (substrate-mTLS, canonical) in D-SUBSTRATE-UNIFIED-1 Stream 6
|
|
24
|
+
# supersedes this drop-in once the Worker route at
|
|
25
|
+
# mcp.truealter.com/substrate/events/{handle}/stream lands; until then
|
|
26
|
+
# this is the transitional shape.
|
|
27
|
+
|
|
28
|
+
[Service]
|
|
29
|
+
EnvironmentFile=-%h/.config/alter/cf-access.env
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Local-transport surfaces exposed by the alter-runtime daemon.
|
|
2
|
+
|
|
3
|
+
Per D-RT2, the daemon exposes identity state on three local transports in
|
|
4
|
+
parallel:
|
|
5
|
+
|
|
6
|
+
* **Unix socket** at ``$XDG_RUNTIME_DIR/alter.sock`` (Linux) / Application
|
|
7
|
+
Support (macOS) - used by the CC hook, shell scripts, and arbitrary
|
|
8
|
+
language-agnostic callers. Line-delimited JSON-RPC.
|
|
9
|
+
* **D-Bus** on the session bus (Linux only, optional dependency on
|
|
10
|
+
``dbus-next``) - used by GNOME / KDE / Waybar modules and any desktop
|
|
11
|
+
tooling that speaks D-Bus natively.
|
|
12
|
+
* **HTTP/SSE loopback** (Wave 3) - not yet implemented.
|
|
13
|
+
|
|
14
|
+
Each transport is a :class:`alter_runtime.daemon.Component` that registers
|
|
15
|
+
with the supervisor and shares the single-instance :class:`EventBus`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from alter_runtime.sockets.unix import UnixSocketServer
|
|
19
|
+
|
|
20
|
+
__all__ = ["UnixSocketServer"]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""DBusService - ``org.alter.Identity1`` on the session bus (Linux only).
|
|
2
|
+
|
|
3
|
+
Exposes the daemon's identity event stream on the Linux session D-Bus under
|
|
4
|
+
the bus name ``org.alter.Identity1`` and object path ``/org/alter/Identity``.
|
|
5
|
+
This is the surface that Waybar / GNOME Shell / KDE Plasmoids consume.
|
|
6
|
+
|
|
7
|
+
Interface
|
|
8
|
+
---------
|
|
9
|
+
|
|
10
|
+
::
|
|
11
|
+
|
|
12
|
+
<interface name="org.alter.Identity1">
|
|
13
|
+
<method name="Whoami">
|
|
14
|
+
<arg name="handle" type="s" direction="out"/>
|
|
15
|
+
<arg name="consent_tier" type="i" direction="out"/>
|
|
16
|
+
</method>
|
|
17
|
+
<method name="Ingest">
|
|
18
|
+
<arg name="kind" type="s" direction="in"/>
|
|
19
|
+
<arg name="payload_json" type="s" direction="in"/>
|
|
20
|
+
<arg name="accepted" type="b" direction="out"/>
|
|
21
|
+
</method>
|
|
22
|
+
<signal name="IdentityEvent">
|
|
23
|
+
<arg name="event_json" type="s"/>
|
|
24
|
+
</signal>
|
|
25
|
+
</interface>
|
|
26
|
+
|
|
27
|
+
``IdentityEvent`` is emitted for every ``identity.event`` bus publish. The
|
|
28
|
+
``event_json`` argument is the JSON-encoded event payload - consumers on the
|
|
29
|
+
desktop side typically JSON-decode it and render a minimal subset
|
|
30
|
+
(attunement, handle, kind) into a status-bar widget.
|
|
31
|
+
|
|
32
|
+
Optional dependency
|
|
33
|
+
-------------------
|
|
34
|
+
|
|
35
|
+
The service is gated on ``dbus-next`` (installed via
|
|
36
|
+
``alter-runtime[dbus]``). If the import fails (module missing, or running on
|
|
37
|
+
a non-Linux platform) the component logs a warning and sleeps on the
|
|
38
|
+
shutdown event - the daemon continues to run with its other surfaces.
|
|
39
|
+
|
|
40
|
+
Tests skip this component unless ``dbus-next`` is importable *and* a session
|
|
41
|
+
bus is available. CI on non-Linux platforms or on headless Linux runners
|
|
42
|
+
without ``dbus-daemon`` will hit the fallback no-op path.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import asyncio
|
|
48
|
+
import json
|
|
49
|
+
import logging
|
|
50
|
+
import sys
|
|
51
|
+
from typing import TYPE_CHECKING, Any
|
|
52
|
+
|
|
53
|
+
from alter_runtime.config import DaemonConfig
|
|
54
|
+
from alter_runtime.daemon import Component
|
|
55
|
+
from alter_runtime.sockets.unix import INGEST_KIND_WHITELIST
|
|
56
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING:
|
|
59
|
+
from alter_runtime.config import Session
|
|
60
|
+
|
|
61
|
+
__all__ = ["DBusService", "INGEST_KIND_WHITELIST", "is_dbus_available"]
|
|
62
|
+
|
|
63
|
+
logger = logging.getLogger("alter_runtime.sockets.dbus")
|
|
64
|
+
|
|
65
|
+
BUS_NAME: str = "org.alter.Identity1"
|
|
66
|
+
OBJECT_PATH: str = "/org/alter/Identity"
|
|
67
|
+
INTERFACE_NAME: str = "org.alter.Identity1"
|
|
68
|
+
|
|
69
|
+
#: Egress topic - mirrors :mod:`alter_runtime.sockets.unix`.
|
|
70
|
+
EGRESS_TOPIC: str = "local.signal"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_dbus_available() -> bool:
|
|
74
|
+
"""Return True if ``dbus-next`` can be imported on this platform.
|
|
75
|
+
|
|
76
|
+
We do the import here rather than at module top so that ``sockets/dbus.py``
|
|
77
|
+
is importable on every platform - the component class has to exist even
|
|
78
|
+
if the optional dependency isn't installed, so the daemon can log a
|
|
79
|
+
helpful warning and continue.
|
|
80
|
+
"""
|
|
81
|
+
if not sys.platform.startswith("linux"):
|
|
82
|
+
return False
|
|
83
|
+
try:
|
|
84
|
+
import dbus_next # noqa: F401
|
|
85
|
+
except ImportError:
|
|
86
|
+
return False
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DBusService(Component):
|
|
91
|
+
"""Exports identity events on the Linux session D-Bus.
|
|
92
|
+
|
|
93
|
+
When the optional ``dbus-next`` dependency is unavailable the component's
|
|
94
|
+
:meth:`run` logs a single warning and blocks on shutdown, so callers can
|
|
95
|
+
register it unconditionally without breaking non-Linux installs.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
config:
|
|
100
|
+
Loaded :class:`DaemonConfig`.
|
|
101
|
+
bus:
|
|
102
|
+
Shared :class:`EventBus` - subscribes to ``identity.event`` to emit
|
|
103
|
+
``IdentityEvent`` signals, and publishes to ``local.signal`` for
|
|
104
|
+
Ingest() callers.
|
|
105
|
+
session:
|
|
106
|
+
Authenticated alter-cli :class:`Session` - used for the ``Whoami``
|
|
107
|
+
method. Optional; Whoami returns empty strings when absent.
|
|
108
|
+
bus_name, object_path, interface_name:
|
|
109
|
+
Test hooks for overriding the D-Bus identifiers. Production code
|
|
110
|
+
should leave these at their defaults.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
name = "dbus"
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
config: DaemonConfig,
|
|
118
|
+
bus: EventBus,
|
|
119
|
+
session: Session | None = None,
|
|
120
|
+
*,
|
|
121
|
+
bus_name: str = BUS_NAME,
|
|
122
|
+
object_path: str = OBJECT_PATH,
|
|
123
|
+
interface_name: str = INTERFACE_NAME,
|
|
124
|
+
) -> None:
|
|
125
|
+
self._config = config
|
|
126
|
+
self._bus = bus
|
|
127
|
+
self._session = session
|
|
128
|
+
self._bus_name = bus_name
|
|
129
|
+
self._object_path = object_path
|
|
130
|
+
self._interface_name = interface_name
|
|
131
|
+
self._stop_event = asyncio.Event()
|
|
132
|
+
self._message_bus: Any = None
|
|
133
|
+
self._interface: Any = None
|
|
134
|
+
self._bus_handler: Any = None
|
|
135
|
+
|
|
136
|
+
async def run(self) -> None:
|
|
137
|
+
"""Start the D-Bus service, or fall back to a quiet no-op."""
|
|
138
|
+
if not is_dbus_available():
|
|
139
|
+
logger.warning(
|
|
140
|
+
"dbus unavailable (install `alter-runtime[dbus]` for the Linux desktop "
|
|
141
|
+
"surface) - continuing without D-Bus export"
|
|
142
|
+
)
|
|
143
|
+
await self._stop_event.wait()
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
await self._start_dbus()
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
logger.warning(
|
|
150
|
+
"dbus failed to start (%s: %s) - continuing without D-Bus export",
|
|
151
|
+
type(exc).__name__,
|
|
152
|
+
exc,
|
|
153
|
+
)
|
|
154
|
+
await self._stop_event.wait()
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
logger.info(
|
|
158
|
+
"dbus exported bus=%s path=%s interface=%s",
|
|
159
|
+
self._bus_name,
|
|
160
|
+
self._object_path,
|
|
161
|
+
self._interface_name,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
await self._stop_event.wait()
|
|
166
|
+
finally:
|
|
167
|
+
await self._teardown_dbus()
|
|
168
|
+
|
|
169
|
+
async def stop(self) -> None:
|
|
170
|
+
"""Cooperative shutdown."""
|
|
171
|
+
self._stop_event.set()
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
# D-Bus lifecycle (only reached when dbus_next is importable)
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
async def _start_dbus(self) -> None:
|
|
178
|
+
"""Connect to the session bus, publish the interface, subscribe to events."""
|
|
179
|
+
# Local imports guarded by is_dbus_available(). Kept inside the
|
|
180
|
+
# function so that test collection on non-Linux machines doesn't
|
|
181
|
+
# choke when the optional dep is missing.
|
|
182
|
+
from dbus_next.aio import MessageBus # type: ignore[import-not-found]
|
|
183
|
+
from dbus_next.service import ( # type: ignore[import-not-found]
|
|
184
|
+
ServiceInterface,
|
|
185
|
+
method,
|
|
186
|
+
signal,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
session = self._session
|
|
190
|
+
egress_bus = self._bus
|
|
191
|
+
loop = asyncio.get_running_loop()
|
|
192
|
+
|
|
193
|
+
class _AlterIdentityInterface(ServiceInterface):
|
|
194
|
+
"""Concrete implementation of the ``org.alter.Identity1`` interface."""
|
|
195
|
+
|
|
196
|
+
def __init__(self, interface_name: str) -> None:
|
|
197
|
+
super().__init__(interface_name)
|
|
198
|
+
|
|
199
|
+
@method()
|
|
200
|
+
def Whoami(self) -> tuple[str, int]: # type: ignore[override]
|
|
201
|
+
if session is None:
|
|
202
|
+
return ("", 0)
|
|
203
|
+
return (session.handle, int(session.consent_tier))
|
|
204
|
+
|
|
205
|
+
@method()
|
|
206
|
+
def Ingest(self, kind: s, payload_json: s) -> b: # type: ignore[override,valid-type] # noqa: F821
|
|
207
|
+
try:
|
|
208
|
+
payload = json.loads(payload_json) if payload_json else {}
|
|
209
|
+
except ValueError:
|
|
210
|
+
return False
|
|
211
|
+
if not isinstance(payload, dict):
|
|
212
|
+
return False
|
|
213
|
+
# Pentest 2026-04-26 (HIGH): mirror the unix-socket
|
|
214
|
+
# ``INGEST_KIND_WHITELIST`` gate - without it any session-
|
|
215
|
+
# bus peer could publish arbitrary ``kind`` values onto the
|
|
216
|
+
# in-process bus, including kinds reserved for trusted
|
|
217
|
+
# internal subscribers (``git_commit``, ``ebpf_exec`` etc.)
|
|
218
|
+
# which downstream consumers would treat as authentic.
|
|
219
|
+
if kind not in INGEST_KIND_WHITELIST:
|
|
220
|
+
logger.warning(
|
|
221
|
+
"dbus rejecting ingest kind=%r (not in whitelist)",
|
|
222
|
+
kind,
|
|
223
|
+
)
|
|
224
|
+
return False
|
|
225
|
+
# Publish from the loop thread - D-Bus calls happen on the
|
|
226
|
+
# same asyncio loop in dbus-next's aio backend. We deliberately
|
|
227
|
+
# fire-and-forget the publish task; the bus guarantees its own
|
|
228
|
+
# in-memory ordering and nothing here awaits the result.
|
|
229
|
+
loop.create_task( # noqa: RUF006
|
|
230
|
+
egress_bus.publish(
|
|
231
|
+
EGRESS_TOPIC,
|
|
232
|
+
{"kind": kind, "payload": payload, "source": "dbus"},
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
@signal()
|
|
238
|
+
def IdentityEvent(self, event_json: s) -> s: # type: ignore[override,valid-type] # noqa: F821
|
|
239
|
+
return event_json
|
|
240
|
+
|
|
241
|
+
self._message_bus = await MessageBus().connect()
|
|
242
|
+
self._interface = _AlterIdentityInterface(self._interface_name)
|
|
243
|
+
self._message_bus.export(self._object_path, self._interface)
|
|
244
|
+
await self._message_bus.request_name(self._bus_name)
|
|
245
|
+
|
|
246
|
+
async def _on_identity_event(payload: dict[str, Any]) -> None:
|
|
247
|
+
if self._interface is None:
|
|
248
|
+
return
|
|
249
|
+
try:
|
|
250
|
+
body = json.dumps(payload, separators=(",", ":"))
|
|
251
|
+
except (TypeError, ValueError):
|
|
252
|
+
logger.debug("dbus skipping non-JSON-serialisable event")
|
|
253
|
+
return
|
|
254
|
+
try:
|
|
255
|
+
self._interface.IdentityEvent(body)
|
|
256
|
+
except Exception as exc: # pragma: no cover
|
|
257
|
+
logger.warning("dbus signal emit failed: %s", exc)
|
|
258
|
+
|
|
259
|
+
self._bus_handler = _on_identity_event
|
|
260
|
+
self._bus.subscribe("identity.event", _on_identity_event)
|
|
261
|
+
|
|
262
|
+
async def _teardown_dbus(self) -> None:
|
|
263
|
+
if self._bus_handler is not None:
|
|
264
|
+
self._bus.unsubscribe("identity.event", self._bus_handler)
|
|
265
|
+
self._bus_handler = None
|
|
266
|
+
if self._message_bus is not None:
|
|
267
|
+
try:
|
|
268
|
+
self._message_bus.disconnect()
|
|
269
|
+
except Exception: # pragma: no cover
|
|
270
|
+
pass
|
|
271
|
+
self._message_bus = None
|
|
272
|
+
self._interface = None
|