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,173 @@
|
|
|
1
|
+
"""HTTP authentication helpers for upstream calls into ``mcp.truealter.com``.
|
|
2
|
+
|
|
3
|
+
The production edge sits behind Cloudflare Access. Sovereign desktop
|
|
4
|
+
daemons authenticate by injecting the ``CF-Access-Client-Id`` +
|
|
5
|
+
``CF-Access-Client-Secret`` headers on every request — without them, CF
|
|
6
|
+
Access intercepts at the edge with a 302 redirect to the
|
|
7
|
+
``truealteraccess.com`` login flow and the daemon's ``Authorization:
|
|
8
|
+
Bearer <jwt>`` is never seen by the origin.
|
|
9
|
+
|
|
10
|
+
This module is the Stream 1 implementation of D-SUBSTRATE-UNIFIED-1 §2.3
|
|
11
|
+
Option A (service-token-per-device, interim posture). Option B
|
|
12
|
+
(substrate-mTLS, canonical) lands in Stream 6 and supersedes this module.
|
|
13
|
+
|
|
14
|
+
Two-tier credential resolution:
|
|
15
|
+
|
|
16
|
+
1. Process environment — ``CF_ACCESS_CLIENT_ID`` + ``CF_ACCESS_CLIENT_SECRET``
|
|
17
|
+
read directly. This is the primary path: systemd ``EnvironmentFile=``,
|
|
18
|
+
launchd ``EnvironmentVariables``, Windows Service env injection,
|
|
19
|
+
subprocess inheritance from a shell that sourced the envfile.
|
|
20
|
+
|
|
21
|
+
2. Envfile fallback — when the process env is unset, parse
|
|
22
|
+
``~/.config/alter/cf-access.env`` directly. This handles the
|
|
23
|
+
cross-platform reality that envfile parsers diverge: bash needs
|
|
24
|
+
``export VAR=value`` for subprocess inheritance, systemd's
|
|
25
|
+
``EnvironmentFile=`` rejects the ``export`` prefix on older versions,
|
|
26
|
+
and launchd's ``EnvironmentVariables`` uses a third format entirely.
|
|
27
|
+
Reading the envfile directly in Python sidesteps every system-level
|
|
28
|
+
parser quirk and keeps the daemon's credential path platform-uniform.
|
|
29
|
+
|
|
30
|
+
The envfile parser is intentionally lenient: it accepts ``VAR=value``,
|
|
31
|
+
``export VAR=value``, surrounding whitespace, simple ``${OTHER_VAR}``
|
|
32
|
+
back-references for the two CF Access vars we care about, and inline
|
|
33
|
+
``#`` comments. It does NOT execute shell — no command substitution, no
|
|
34
|
+
glob expansion, no arithmetic. The only goal is to extract two strings.
|
|
35
|
+
|
|
36
|
+
When both tiers return nothing the helper returns ``{}`` — never raises,
|
|
37
|
+
never logs at warning level. The HTTP client treats absent CF Access
|
|
38
|
+
credentials as "no edge auth required" and proceeds with the existing
|
|
39
|
+
session-JWT bearer. Dev hosts, CI runners, and tests are unaffected.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
__all__ = ["cf_access_default_headers", "backend_default_headers"]
|
|
49
|
+
|
|
50
|
+
_logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
#: Canonical envfile location. Honours ``XDG_CONFIG_HOME`` for the same
|
|
53
|
+
#: reason ``alter_runtime.config.session_path()`` does — most users
|
|
54
|
+
#: leave it unset and the file lives under ``~/.config/alter/``.
|
|
55
|
+
_ENVFILE_RELATIVE: str = ".config/alter/cf-access.env"
|
|
56
|
+
|
|
57
|
+
#: The two CF Access service-token variable names the edge requires.
|
|
58
|
+
#: ``CF_ACCESS_MCP_*`` aliases are deliberately not consulted — the
|
|
59
|
+
#: cross-bridge ``cf-access.env`` shape exports a generic alias
|
|
60
|
+
#: (``CF_ACCESS_CLIENT_ID``) that we treat as authoritative.
|
|
61
|
+
_ID_VAR: str = "CF_ACCESS_CLIENT_ID"
|
|
62
|
+
_SECRET_VAR: str = "CF_ACCESS_CLIENT_SECRET"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cf_access_default_headers() -> dict[str, str]:
|
|
66
|
+
"""Return CF Access service-token headers from env or envfile.
|
|
67
|
+
|
|
68
|
+
Resolution order:
|
|
69
|
+
|
|
70
|
+
1. Process environment (``CF_ACCESS_CLIENT_ID`` /
|
|
71
|
+
``CF_ACCESS_CLIENT_SECRET``).
|
|
72
|
+
2. ``~/.config/alter/cf-access.env`` parsed by
|
|
73
|
+
:func:`_parse_envfile`.
|
|
74
|
+
|
|
75
|
+
Returns the two-key header dict when both credentials resolve to
|
|
76
|
+
non-empty strings; returns ``{}`` otherwise. Never raises.
|
|
77
|
+
"""
|
|
78
|
+
client_id = os.environ.get(_ID_VAR, "").strip()
|
|
79
|
+
client_secret = os.environ.get(_SECRET_VAR, "").strip()
|
|
80
|
+
|
|
81
|
+
if not client_id or not client_secret:
|
|
82
|
+
envfile_values = _read_envfile_safely()
|
|
83
|
+
client_id = client_id or envfile_values.get(_ID_VAR, "").strip()
|
|
84
|
+
client_secret = client_secret or envfile_values.get(_SECRET_VAR, "").strip()
|
|
85
|
+
|
|
86
|
+
if not client_id or not client_secret:
|
|
87
|
+
return {}
|
|
88
|
+
return {
|
|
89
|
+
"CF-Access-Client-Id": client_id,
|
|
90
|
+
"CF-Access-Client-Secret": client_secret,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def backend_default_headers() -> dict[str, str]:
|
|
95
|
+
"""Return headers attached to every outbound backend HTTP call.
|
|
96
|
+
|
|
97
|
+
Merges the canonical ``X-Alter-Client-*`` identity bundle
|
|
98
|
+
(D-MIN-VERSION-FLOOR-1 §3) with CF Access service-token headers when
|
|
99
|
+
available. Backend callsites use this in place of
|
|
100
|
+
:func:`cf_access_default_headers` so the floor middleware sees a
|
|
101
|
+
well-formed identification tuple on every request.
|
|
102
|
+
|
|
103
|
+
Header precedence: identity headers (``X-Alter-Client-*``) are constant
|
|
104
|
+
per process; CF Access headers are loaded lazily and overlay on top
|
|
105
|
+
(CF Access keys never collide with the X-Alter-* namespace, but the
|
|
106
|
+
overlay order keeps the contract explicit).
|
|
107
|
+
|
|
108
|
+
Defined here rather than in ``alter_runtime.floor_preflight`` so legacy
|
|
109
|
+
callers can adopt the helper without taking on the floor-preflight
|
|
110
|
+
import surface. Both modules cross-import each other otherwise.
|
|
111
|
+
"""
|
|
112
|
+
# Lazy import to avoid the http_auth → floor_preflight → http_auth cycle.
|
|
113
|
+
from alter_runtime.floor_preflight import client_headers
|
|
114
|
+
|
|
115
|
+
merged: dict[str, str] = dict(client_headers())
|
|
116
|
+
merged.update(cf_access_default_headers())
|
|
117
|
+
return merged
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _read_envfile_safely() -> dict[str, str]:
|
|
121
|
+
"""Read ``~/.config/alter/cf-access.env`` if present and parseable."""
|
|
122
|
+
path = Path.home() / _ENVFILE_RELATIVE
|
|
123
|
+
try:
|
|
124
|
+
text = path.read_text(encoding="utf-8")
|
|
125
|
+
except FileNotFoundError:
|
|
126
|
+
return {}
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
_logger.debug("cf-access envfile read failed at %s: %s", path, exc)
|
|
129
|
+
return {}
|
|
130
|
+
try:
|
|
131
|
+
return _parse_envfile(text)
|
|
132
|
+
except Exception as exc: # noqa: BLE001 — last-resort safety
|
|
133
|
+
_logger.debug("cf-access envfile parse failed at %s: %s", path, exc)
|
|
134
|
+
return {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_envfile(text: str) -> dict[str, str]:
|
|
138
|
+
"""Parse a lenient envfile into a name→value dict.
|
|
139
|
+
|
|
140
|
+
Accepts ``VAR=value``, ``export VAR=value``, surrounding whitespace,
|
|
141
|
+
inline ``#`` comments, and single-level ``${OTHER}`` back-references
|
|
142
|
+
against names defined earlier in the same file (which covers the
|
|
143
|
+
canonical ``cf-access.env`` shape that aliases ``CF_ACCESS_CLIENT_ID``
|
|
144
|
+
onto ``CF_ACCESS_MCP_ORG_ALTER_CLIENT_ID``). Does not invoke a
|
|
145
|
+
shell; no command substitution, no globbing, no arithmetic.
|
|
146
|
+
"""
|
|
147
|
+
out: dict[str, str] = {}
|
|
148
|
+
for raw in text.splitlines():
|
|
149
|
+
line = raw.strip()
|
|
150
|
+
if not line or line.startswith("#"):
|
|
151
|
+
continue
|
|
152
|
+
if line.startswith("export "):
|
|
153
|
+
line = line[len("export ") :].lstrip()
|
|
154
|
+
if "=" not in line:
|
|
155
|
+
continue
|
|
156
|
+
name, _, value = line.partition("=")
|
|
157
|
+
name = name.strip()
|
|
158
|
+
if not name or not name.replace("_", "").isalnum():
|
|
159
|
+
continue
|
|
160
|
+
# Strip inline comment (only when preceded by whitespace, so
|
|
161
|
+
# values containing literal '#' inside quotes survive).
|
|
162
|
+
if " #" in value:
|
|
163
|
+
value = value.split(" #", 1)[0]
|
|
164
|
+
value = value.strip()
|
|
165
|
+
# Strip a single layer of surrounding single or double quotes.
|
|
166
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
167
|
+
value = value[1:-1]
|
|
168
|
+
# Resolve ${OTHER} back-references against names already parsed.
|
|
169
|
+
if value.startswith("${") and value.endswith("}"):
|
|
170
|
+
ref = value[2:-1].strip()
|
|
171
|
+
value = out.get(ref, "")
|
|
172
|
+
out[name] = value
|
|
173
|
+
return out
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Notifiers - local fan-out surfaces that render identity events to the user's desktop.
|
|
2
|
+
|
|
3
|
+
Each notifier is a :class:`alter_runtime.daemon.Component` that subscribes to
|
|
4
|
+
the shared :class:`alter_runtime.subscribers.bus.EventBus` and renders a
|
|
5
|
+
matching event to a platform-native surface: freedesktop notifications on
|
|
6
|
+
Linux, NSUserNotificationCenter on macOS, toast-XML on Windows. The notifier
|
|
7
|
+
tier sits downstream of the InboxWriter projection - they consume the same
|
|
8
|
+
bus topic, so a notifier failure cannot starve the inbox write path and vice
|
|
9
|
+
versa.
|
|
10
|
+
|
|
11
|
+
Wave 1 ships :class:`DesktopNotifier` (Linux ``notify-send``). macOS / Windows
|
|
12
|
+
paths are deferred; the notifier tier is Linux-only for the private-dev
|
|
13
|
+
dogfood launch.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from alter_runtime.notifiers.desktop import DesktopNotifier
|
|
17
|
+
|
|
18
|
+
__all__ = ["DesktopNotifier"]
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""DesktopNotifier - freedesktop toast on inbound ``alter_message``.
|
|
2
|
+
|
|
3
|
+
Subscribes to the same ``identity.event`` stream the :class:`InboxWriter`
|
|
4
|
+
drains and shells out to ``/usr/bin/notify-send`` on each inbound message
|
|
5
|
+
addressed to the local ``~handle``. Designed for Blake's awesomewm rig but
|
|
6
|
+
will work on any freedesktop.org-compliant desktop (GNOME, KDE, sway, i3,
|
|
7
|
+
bspwm, awesome, dwm + dunst, etc.) without configuration.
|
|
8
|
+
|
|
9
|
+
Design choices
|
|
10
|
+
--------------
|
|
11
|
+
|
|
12
|
+
* **Native feel.** Uses ``notify-send`` rather than the ``dbus-next`` path so
|
|
13
|
+
existing notification daemons (dunst, mako, the GNOME/KDE services) pick up
|
|
14
|
+
the message through the standard ``org.freedesktop.Notifications`` bus
|
|
15
|
+
interface. The flags passed match a messaging-app toast: ``--app-name`` +
|
|
16
|
+
``--category="im.received"`` + ``--icon=mail-unread`` + a ``sound-name``
|
|
17
|
+
hint so the user's notification daemon plays the system message chime.
|
|
18
|
+
* **Non-intrusive.** ``--urgency=normal`` (not critical) means the toast is
|
|
19
|
+
dismissible and does not steal focus. ``--expire-time=8000`` keeps it on
|
|
20
|
+
screen for 8s, long enough to read the first line without becoming a
|
|
21
|
+
permanent shade.
|
|
22
|
+
* **Swallow and continue.** Every failure class (``notify-send`` missing, no
|
|
23
|
+
``DISPLAY``/``WAYLAND_DISPLAY``, dbus not running, subprocess crash) is
|
|
24
|
+
logged at warning level and dropped. The notifier is a convenience surface;
|
|
25
|
+
the inbox JSONL is the source of truth and already landed on disk by the
|
|
26
|
+
time we see the event.
|
|
27
|
+
* **Privacy invariants (D-MSG10).** The notifier is a one-way render. It
|
|
28
|
+
never emits a read receipt, presence beacon, or typing indicator back onto
|
|
29
|
+
the bus. The only side effect is the toast itself.
|
|
30
|
+
|
|
31
|
+
The component ignores events whose ``kind`` is not ``"alter_message"`` and
|
|
32
|
+
events addressed to a different recipient (e.g. room-wide broadcasts with an
|
|
33
|
+
explicit ``recipient_handle`` that is not the local session handle).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import shutil
|
|
43
|
+
from typing import TYPE_CHECKING, Any
|
|
44
|
+
|
|
45
|
+
from alter_runtime.daemon import Component
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from alter_runtime.config import DaemonConfig, Session
|
|
49
|
+
from alter_runtime.subscribers.bus import EventBus
|
|
50
|
+
|
|
51
|
+
__all__ = ["DesktopNotifier"]
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger("alter_runtime.notifiers.desktop")
|
|
54
|
+
|
|
55
|
+
#: Absolute path to ``notify-send``. Hard-coded to match the flag set validated
|
|
56
|
+
#: against libnotify 0.8.x; a PATH lookup at run-time would be more flexible
|
|
57
|
+
#: but would also silently pick up stub binaries on sandboxed hosts.
|
|
58
|
+
NOTIFY_SEND_BIN: str = "/usr/bin/notify-send"
|
|
59
|
+
|
|
60
|
+
#: Maximum characters of ``body_md`` rendered in the toast body. Kept short so
|
|
61
|
+
#: the notification stays a glance rather than a read. The InboxWriter / CLI
|
|
62
|
+
#: ``alter msg read`` surfaces render the full body.
|
|
63
|
+
BODY_PREVIEW_CHARS: int = 140
|
|
64
|
+
|
|
65
|
+
#: Default toast dwell time in milliseconds (8s - long enough to read, short
|
|
66
|
+
#: enough that it is gone before the user finishes a cigarette).
|
|
67
|
+
EXPIRE_TIME_MS: int = 8000
|
|
68
|
+
|
|
69
|
+
#: App-name displayed in the notification centre. Matches the brand wordmark.
|
|
70
|
+
APP_NAME: str = "Alter"
|
|
71
|
+
|
|
72
|
+
#: FDO category; ``im.received`` is the canonical message-arrival category so
|
|
73
|
+
#: notification daemons (dunst et al.) can route it to a chat rule.
|
|
74
|
+
CATEGORY: str = "im.received"
|
|
75
|
+
|
|
76
|
+
#: libnotify icon name; ``mail-unread`` is present in every freedesktop icon
|
|
77
|
+
#: theme we care about. Falls back silently if absent.
|
|
78
|
+
ICON: str = "mail-unread"
|
|
79
|
+
|
|
80
|
+
#: Sound hint - ``message-new-instant`` is the sound-theme canonical event
|
|
81
|
+
#: for an incoming IM. Most themes map it to a short chime.
|
|
82
|
+
SOUND_NAME: str = "message-new-instant"
|
|
83
|
+
|
|
84
|
+
#: Markdown stripping - good enough for toast previews. Removes emphasis
|
|
85
|
+
#: markers, headings, code fences, inline links, and collapses newlines.
|
|
86
|
+
_MD_BOLD_ITALIC = re.compile(r"(\*{1,3}|_{1,3})(.+?)\1")
|
|
87
|
+
_MD_CODE_INLINE = re.compile(r"`([^`]+)`")
|
|
88
|
+
_MD_CODE_FENCE = re.compile(r"```[\s\S]*?```")
|
|
89
|
+
_MD_LINK = re.compile(r"\[([^\]]+)\]\([^)]+\)")
|
|
90
|
+
_MD_HEADING = re.compile(r"^#{1,6}\s+", re.MULTILINE)
|
|
91
|
+
_MD_BLOCKQUOTE = re.compile(r"^>\s?", re.MULTILINE)
|
|
92
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _strip_markdown(text: str) -> str:
|
|
96
|
+
"""Flatten a Markdown string to a single-line toast body."""
|
|
97
|
+
if not text:
|
|
98
|
+
return ""
|
|
99
|
+
text = _MD_CODE_FENCE.sub("", text)
|
|
100
|
+
text = _MD_CODE_INLINE.sub(r"\1", text)
|
|
101
|
+
text = _MD_LINK.sub(r"\1", text)
|
|
102
|
+
text = _MD_HEADING.sub("", text)
|
|
103
|
+
text = _MD_BLOCKQUOTE.sub("", text)
|
|
104
|
+
text = _MD_BOLD_ITALIC.sub(r"\2", text)
|
|
105
|
+
text = _WHITESPACE.sub(" ", text).strip()
|
|
106
|
+
return text
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _truncate(text: str, limit: int = BODY_PREVIEW_CHARS) -> str:
|
|
110
|
+
"""Truncate ``text`` to ``limit`` chars with an ellipsis marker."""
|
|
111
|
+
if len(text) <= limit:
|
|
112
|
+
return text
|
|
113
|
+
return text[: limit - 1].rstrip() + "…"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class DesktopNotifier(Component):
|
|
117
|
+
"""Renders inbound ``alter_message`` events to a freedesktop toast.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
config:
|
|
122
|
+
Loaded :class:`DaemonConfig`. Not currently consulted - kept for
|
|
123
|
+
future opt-in fields (urgency override, custom icon theme, etc.).
|
|
124
|
+
session:
|
|
125
|
+
Authenticated alter-cli session. ``session.handle`` is used to
|
|
126
|
+
filter messages addressed to a different recipient.
|
|
127
|
+
bus:
|
|
128
|
+
The shared :class:`EventBus`. Subscribes to ``identity.event``.
|
|
129
|
+
notify_send_bin:
|
|
130
|
+
Override the path to ``notify-send``. Tests inject a stub here.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
name = "desktop_notifier"
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
config: DaemonConfig,
|
|
138
|
+
session: Session,
|
|
139
|
+
bus: EventBus,
|
|
140
|
+
*,
|
|
141
|
+
notify_send_bin: str = NOTIFY_SEND_BIN,
|
|
142
|
+
) -> None:
|
|
143
|
+
self._config = config
|
|
144
|
+
self._session = session
|
|
145
|
+
self._bus = bus
|
|
146
|
+
self._notify_send_bin = notify_send_bin
|
|
147
|
+
self._stop_event = asyncio.Event()
|
|
148
|
+
#: Monotonic count of toasts actually rendered (test introspection).
|
|
149
|
+
self._toasts_sent: int = 0
|
|
150
|
+
#: Monotonic count of send failures (test introspection).
|
|
151
|
+
self._send_failures: int = 0
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Component lifecycle
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
async def run(self) -> None:
|
|
158
|
+
# Refuse to pretend we are running a notifier if the binary is missing
|
|
159
|
+
# or no graphical session exists - log loudly once, then sleep. The
|
|
160
|
+
# supervisor will keep us alive (so a later ``DISPLAY`` export picks
|
|
161
|
+
# up) without us spamming a failed subprocess on every message.
|
|
162
|
+
if not self._has_usable_backend():
|
|
163
|
+
logger.warning(
|
|
164
|
+
"desktop_notifier: notify-send or graphical session unavailable; "
|
|
165
|
+
"notifier started in no-op mode (messages still reach inbox.jsonl)"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
self._bus.subscribe("identity.event", self._on_event)
|
|
169
|
+
logger.info(
|
|
170
|
+
"desktop_notifier started handle=%s bin=%s",
|
|
171
|
+
self._session.handle,
|
|
172
|
+
self._notify_send_bin,
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
await self._stop_event.wait()
|
|
176
|
+
finally:
|
|
177
|
+
self._bus.unsubscribe("identity.event", self._on_event)
|
|
178
|
+
logger.info(
|
|
179
|
+
"desktop_notifier stopped handle=%s toasts=%d failures=%d",
|
|
180
|
+
self._session.handle,
|
|
181
|
+
self._toasts_sent,
|
|
182
|
+
self._send_failures,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def stop(self) -> None:
|
|
186
|
+
self._stop_event.set()
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
# Bus callback
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
async def _on_event(self, event: Any) -> None:
|
|
193
|
+
"""Filter for alter_message addressed to the local handle and render."""
|
|
194
|
+
if not isinstance(event, dict):
|
|
195
|
+
return
|
|
196
|
+
if event.get("kind") != "alter_message":
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# Accept the same dual-shape the InboxWriter does: payload may be
|
|
200
|
+
# nested or flat. Prefer the nested body because the DO emitter puts
|
|
201
|
+
# the message fields there.
|
|
202
|
+
body = event.get("payload") if isinstance(event.get("payload"), dict) else event
|
|
203
|
+
|
|
204
|
+
# Recipient filter - a frame with an explicit recipient that isn't us
|
|
205
|
+
# is probably a room broadcast or a multi-cast and the desktop should
|
|
206
|
+
# not light up. Frames without a recipient field are treated as
|
|
207
|
+
# implicitly-for-us (the DO only emits messages on the owner's stream).
|
|
208
|
+
recipient = body.get("recipient_handle") or body.get("recipient")
|
|
209
|
+
if recipient and recipient != self._session.handle:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
sender = body.get("sender_handle") or body.get("sender") or "~unknown"
|
|
213
|
+
body_md = body.get("body_md") or ""
|
|
214
|
+
preview = _truncate(_strip_markdown(str(body_md)))
|
|
215
|
+
|
|
216
|
+
await self._toast(sender=str(sender), preview=preview)
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
# notify-send invocation
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
async def _toast(self, *, sender: str, preview: str) -> None:
|
|
223
|
+
"""Fire a ``notify-send`` with the canonical messaging flags."""
|
|
224
|
+
if not self._has_usable_backend():
|
|
225
|
+
# Logged once in run(); stay quiet here to avoid spam.
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
title = sender
|
|
229
|
+
body = preview or "(no body)"
|
|
230
|
+
|
|
231
|
+
argv = [
|
|
232
|
+
self._notify_send_bin,
|
|
233
|
+
f"--app-name={APP_NAME}",
|
|
234
|
+
"--urgency=normal",
|
|
235
|
+
f"--expire-time={EXPIRE_TIME_MS}",
|
|
236
|
+
f"--category={CATEGORY}",
|
|
237
|
+
f"--icon={ICON}",
|
|
238
|
+
f"--hint=string:sound-name:{SOUND_NAME}",
|
|
239
|
+
title,
|
|
240
|
+
body,
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
proc = await asyncio.create_subprocess_exec(
|
|
245
|
+
*argv,
|
|
246
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
247
|
+
stderr=asyncio.subprocess.PIPE,
|
|
248
|
+
)
|
|
249
|
+
try:
|
|
250
|
+
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=5.0)
|
|
251
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
252
|
+
# Kill a stuck notify-send rather than leaking the subprocess.
|
|
253
|
+
proc.kill()
|
|
254
|
+
await proc.wait()
|
|
255
|
+
self._send_failures += 1
|
|
256
|
+
logger.warning("desktop_notifier: notify-send timed out; killed child")
|
|
257
|
+
return
|
|
258
|
+
except FileNotFoundError:
|
|
259
|
+
self._send_failures += 1
|
|
260
|
+
logger.warning(
|
|
261
|
+
"desktop_notifier: %s not found - set ALTER_DESKTOP_NOTIFIER_DISABLED=1 "
|
|
262
|
+
"to silence this warning",
|
|
263
|
+
self._notify_send_bin,
|
|
264
|
+
)
|
|
265
|
+
return
|
|
266
|
+
except Exception as exc: # noqa: BLE001 - swallow every class of OS failure
|
|
267
|
+
self._send_failures += 1
|
|
268
|
+
logger.warning("desktop_notifier: spawn failed: %s", exc)
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
if proc.returncode != 0:
|
|
272
|
+
self._send_failures += 1
|
|
273
|
+
err = (stderr or b"").decode("utf-8", errors="replace").strip()
|
|
274
|
+
logger.warning(
|
|
275
|
+
"desktop_notifier: notify-send exit=%d stderr=%r",
|
|
276
|
+
proc.returncode,
|
|
277
|
+
err[:200],
|
|
278
|
+
)
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
self._toasts_sent += 1
|
|
282
|
+
logger.debug("desktop_notifier: toast sent sender=%s", sender)
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
# Environment probes
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def _has_usable_backend(self) -> bool:
|
|
289
|
+
"""Best-effort check that notify-send can actually post a toast.
|
|
290
|
+
|
|
291
|
+
Does not guarantee success - a running dbus session without a
|
|
292
|
+
notification daemon will still accept the call but drop it silently -
|
|
293
|
+
but catches the obvious cold-fail cases so we log once rather than
|
|
294
|
+
per-message.
|
|
295
|
+
"""
|
|
296
|
+
if not shutil.which(self._notify_send_bin) and not os.path.exists(self._notify_send_bin):
|
|
297
|
+
return False
|
|
298
|
+
# Either X11 or Wayland session indicator is fine. Headless systemd
|
|
299
|
+
# services inherit ``XDG_RUNTIME_DIR`` but not ``DISPLAY``; dbus over
|
|
300
|
+
# the user bus works without either, so this is a soft heuristic.
|
|
301
|
+
if (
|
|
302
|
+
not os.environ.get("DISPLAY")
|
|
303
|
+
and not os.environ.get("WAYLAND_DISPLAY")
|
|
304
|
+
and not os.environ.get("DBUS_SESSION_BUS_ADDRESS")
|
|
305
|
+
):
|
|
306
|
+
return False
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
# ------------------------------------------------------------------
|
|
310
|
+
# Test introspection
|
|
311
|
+
# ------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def toasts_sent(self) -> int:
|
|
315
|
+
"""Count of toasts actually rendered (used by tests)."""
|
|
316
|
+
return self._toasts_sent
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def send_failures(self) -> int:
|
|
320
|
+
"""Count of ``notify-send`` spawn / exit failures (used by tests)."""
|
|
321
|
+
return self._send_failures
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""alter_runtime.sdk - Python SDK for ALTER's identity MCP server.
|
|
2
|
+
|
|
3
|
+
Exports ``AlterClient``, the async HTTP client lifted from
|
|
4
|
+
``backend/openclaw-skill/alter_bot/mcp_client.py`` and renamed for the
|
|
5
|
+
distribution layer (D-RT5 - the PyPI package is ``alter-runtime``, not
|
|
6
|
+
``alter-identity``, because the TypeScript npm package ``@alter/identity``
|
|
7
|
+
already claims that name).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from alter_runtime.sdk.client import AlterClient, MCPResponse
|
|
11
|
+
|
|
12
|
+
__all__ = ["AlterClient", "MCPResponse"]
|