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
alter_runtime/config.py
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""XDG-compliant configuration and session loading.
|
|
2
|
+
|
|
3
|
+
The alter-runtime daemon reads its configuration from three sources, in order
|
|
4
|
+
of precedence:
|
|
5
|
+
|
|
6
|
+
1. Environment variables (ALTER_RUNTIME_*)
|
|
7
|
+
2. User config file at $XDG_CONFIG_HOME/alter/runtime.yaml (default
|
|
8
|
+
~/.config/alter/runtime.yaml)
|
|
9
|
+
3. Built-in defaults
|
|
10
|
+
|
|
11
|
+
The authoritative identity session is written by the separate `alter-cli`
|
|
12
|
+
sibling repo at $XDG_CONFIG_HOME/alter/session.json. We read it but never
|
|
13
|
+
mutate it - the daemon does not manage login.
|
|
14
|
+
|
|
15
|
+
Paths per D-RT2 and the plan's macOS socket location decision:
|
|
16
|
+
- Linux: /run/user/$UID/alter.sock
|
|
17
|
+
- macOS: ~/Library/Application Support/alter/runtime.sock
|
|
18
|
+
- Windows: \\\\.\\pipe\\alter-runtime (Wave 3; Named Pipe, not Unix socket)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ConfigError",
|
|
32
|
+
"DaemonConfig",
|
|
33
|
+
"Session",
|
|
34
|
+
"cache_dir",
|
|
35
|
+
"config_dir",
|
|
36
|
+
"data_dir",
|
|
37
|
+
"keypair_path",
|
|
38
|
+
"load_config",
|
|
39
|
+
"load_session",
|
|
40
|
+
"runtime_state_dir",
|
|
41
|
+
"state_dir",
|
|
42
|
+
"unix_socket_path",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# XDG helpers
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _xdg_path(env_var: str, fallback: str) -> Path:
|
|
52
|
+
"""Return $env_var if set, else $HOME/fallback."""
|
|
53
|
+
raw = os.environ.get(env_var)
|
|
54
|
+
if raw:
|
|
55
|
+
return Path(raw).expanduser()
|
|
56
|
+
return Path.home() / fallback
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def config_dir() -> Path:
|
|
60
|
+
"""Return $XDG_CONFIG_HOME/alter (default ~/.config/alter)."""
|
|
61
|
+
return _xdg_path("XDG_CONFIG_HOME", ".config") / "alter"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cache_dir() -> Path:
|
|
65
|
+
"""Return $XDG_CACHE_HOME/alter (default ~/.cache/alter)."""
|
|
66
|
+
return _xdg_path("XDG_CACHE_HOME", ".cache") / "alter"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def state_dir() -> Path:
|
|
70
|
+
"""Return $XDG_STATE_HOME/alter (default ~/.local/state/alter)."""
|
|
71
|
+
return _xdg_path("XDG_STATE_HOME", ".local/state") / "alter"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def data_dir() -> Path:
|
|
75
|
+
"""Return $XDG_DATA_HOME/alter-runtime (default ~/.local/share/alter-runtime).
|
|
76
|
+
|
|
77
|
+
The directory is created on first access with mode ``0o700`` so that the
|
|
78
|
+
``inbox.jsonl`` cache cannot be world-read. Used by the InboxWriter
|
|
79
|
+
subscriber to persist alter-to-alter messages projected from the per-handle
|
|
80
|
+
DO SSE stream (§6.3 of *Alter-to-Alter Messaging*).
|
|
81
|
+
"""
|
|
82
|
+
path = _xdg_path("XDG_DATA_HOME", ".local/share") / "alter-runtime"
|
|
83
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
84
|
+
return path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def runtime_state_dir() -> Path:
|
|
88
|
+
"""Return $XDG_STATE_HOME/alter-runtime (default ~/.local/state/alter-runtime).
|
|
89
|
+
|
|
90
|
+
Distinct from :func:`state_dir` (which is the broader ``alter`` namespace
|
|
91
|
+
used by the alter-cli) - the runtime daemon owns its own state subtree so
|
|
92
|
+
that crash-recovery checkpoints (e.g. ``messaging.json``) cannot collide
|
|
93
|
+
with state written by sibling tools. Created on first access with mode
|
|
94
|
+
``0o700``.
|
|
95
|
+
"""
|
|
96
|
+
path = _xdg_path("XDG_STATE_HOME", ".local/state") / "alter-runtime"
|
|
97
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
98
|
+
return path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def keypair_path() -> Path:
|
|
102
|
+
"""Path to the device Ed25519 keypair (generated by `alter-runtime init`)."""
|
|
103
|
+
return config_dir() / "keypair.json"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def unix_socket_path() -> Path:
|
|
107
|
+
"""Platform-appropriate local socket path.
|
|
108
|
+
|
|
109
|
+
On Linux we prefer $XDG_RUNTIME_DIR (which maps to /run/user/$UID on
|
|
110
|
+
systemd distros), falling back to /tmp. On macOS the socket lives under
|
|
111
|
+
Application Support because /run/user doesn't exist.
|
|
112
|
+
"""
|
|
113
|
+
if sys.platform == "darwin":
|
|
114
|
+
return Path.home() / "Library" / "Application Support" / "alter" / "runtime.sock"
|
|
115
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR")
|
|
116
|
+
if runtime:
|
|
117
|
+
return Path(runtime) / "alter.sock"
|
|
118
|
+
# Fallback: per-user socket under /tmp
|
|
119
|
+
uid = os.getuid() if hasattr(os, "getuid") else 0
|
|
120
|
+
return Path(f"/tmp/alter-{uid}.sock")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Session (written by alter-cli, read-only here)
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class Session:
|
|
130
|
+
"""Snapshot of the alter-cli session.json file.
|
|
131
|
+
|
|
132
|
+
Mirrors the shape from alter-cli/src/commands/login.ts:229-255.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
handle: str
|
|
136
|
+
api: str
|
|
137
|
+
jwt: str
|
|
138
|
+
jwt_expires_at: str
|
|
139
|
+
consent_tier: int
|
|
140
|
+
user_id: str
|
|
141
|
+
email: str
|
|
142
|
+
orgs: list[dict[str, Any]] = field(default_factory=list)
|
|
143
|
+
id_token: str | None = None
|
|
144
|
+
refresh_token: str | None = None
|
|
145
|
+
refresh_expires_at: str | None = None
|
|
146
|
+
logged_in_at: str | None = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def session_path() -> Path:
|
|
150
|
+
"""Path to alter-cli's session.json."""
|
|
151
|
+
override = os.environ.get("ALTER_SESSION_FILE")
|
|
152
|
+
if override:
|
|
153
|
+
return Path(override).expanduser()
|
|
154
|
+
return config_dir() / "session.json"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _parse_consent_tier(value: Any) -> int:
|
|
158
|
+
"""Normalise ``consent_tier`` to the integer ALTER uses internally.
|
|
159
|
+
|
|
160
|
+
The alter-cli TypeScript side writes the consent tier as a string label
|
|
161
|
+
(``"L1"``, ``"L2"``, ``"L3"``, ``"L4"``) - see
|
|
162
|
+
``packages/alter-identity/src/types.ts`` and the canonical session.json
|
|
163
|
+
written by ``alter login``. The Python runtime stores it as the numeric
|
|
164
|
+
tier. Accept both shapes plus the fallback default so a hand-edited
|
|
165
|
+
session file cannot brick the daemon.
|
|
166
|
+
"""
|
|
167
|
+
if isinstance(value, bool):
|
|
168
|
+
# bool is a subclass of int - reject it explicitly to avoid
|
|
169
|
+
# True → 1 sneak-through from a typo in session.json.
|
|
170
|
+
return 1
|
|
171
|
+
if isinstance(value, int):
|
|
172
|
+
if 1 <= value <= 4:
|
|
173
|
+
return value
|
|
174
|
+
return 1
|
|
175
|
+
if isinstance(value, str):
|
|
176
|
+
stripped = value.strip()
|
|
177
|
+
if not stripped:
|
|
178
|
+
return 1
|
|
179
|
+
# "L1".."L4" → 1..4
|
|
180
|
+
if len(stripped) == 2 and stripped[0] in ("L", "l") and stripped[1].isdigit():
|
|
181
|
+
tier = int(stripped[1])
|
|
182
|
+
if 1 <= tier <= 4:
|
|
183
|
+
return tier
|
|
184
|
+
# Raw numeric strings "1".."4" as a last resort.
|
|
185
|
+
try:
|
|
186
|
+
tier = int(stripped)
|
|
187
|
+
except ValueError:
|
|
188
|
+
return 1
|
|
189
|
+
if 1 <= tier <= 4:
|
|
190
|
+
return tier
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def load_session() -> Session | None:
|
|
195
|
+
"""Load the alter-cli session, returning ``None`` if absent/invalid.
|
|
196
|
+
|
|
197
|
+
The daemon treats an absent session as "not yet logged in" and continues
|
|
198
|
+
running in degraded mode (can still ingest signals once the user runs
|
|
199
|
+
`alter login`).
|
|
200
|
+
"""
|
|
201
|
+
path = session_path()
|
|
202
|
+
if not path.exists():
|
|
203
|
+
return None
|
|
204
|
+
try:
|
|
205
|
+
raw = json.loads(path.read_text())
|
|
206
|
+
except (json.JSONDecodeError, OSError):
|
|
207
|
+
return None
|
|
208
|
+
try:
|
|
209
|
+
session = Session(
|
|
210
|
+
handle=raw["handle"],
|
|
211
|
+
api=raw.get("api", "https://api.truealter.com"),
|
|
212
|
+
jwt=raw["jwt"],
|
|
213
|
+
jwt_expires_at=raw["jwt_expires_at"],
|
|
214
|
+
consent_tier=_parse_consent_tier(raw.get("consent_tier", 1)),
|
|
215
|
+
user_id=raw.get("user_id", ""),
|
|
216
|
+
email=raw.get("email", ""),
|
|
217
|
+
orgs=list(raw.get("orgs", [])),
|
|
218
|
+
id_token=raw.get("id_token"),
|
|
219
|
+
refresh_token=raw.get("refresh_token"),
|
|
220
|
+
refresh_expires_at=raw.get("refresh_expires_at"),
|
|
221
|
+
logged_in_at=raw.get("logged_in_at"),
|
|
222
|
+
)
|
|
223
|
+
except KeyError:
|
|
224
|
+
return None
|
|
225
|
+
# M-E-1 (pentest-pass2 §6.4): reject http:// api endpoints - the subscriber
|
|
226
|
+
# POSTs session.jwt as a Bearer token; plaintext delivery is not acceptable.
|
|
227
|
+
try:
|
|
228
|
+
_require_https("session.api", session.api)
|
|
229
|
+
except ConfigError:
|
|
230
|
+
return None
|
|
231
|
+
return session
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# Daemon configuration
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Pre-T-0 hardening (C-7 / M-J kill-switch, 2026-04-14)
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
#
|
|
243
|
+
# The L3 daemon accepts IdentityEvent frames from its SSE subscriber and
|
|
244
|
+
# projects them into ~/.cache/alter/identity.json and the inbox. Pre-T-0 the
|
|
245
|
+
# full Ed25519 frame-signature verification (which needs a DO-side signer
|
|
246
|
+
# counterpart) is out of scope for today; this module ships the *short-term*
|
|
247
|
+
# kill-switch instead:
|
|
248
|
+
#
|
|
249
|
+
# 1. ``http://`` endpoints are rejected with a loud error at load - TLS is
|
|
250
|
+
# not optional.
|
|
251
|
+
# 2. The ``runtime.yaml`` endpoint override (which previously let any local
|
|
252
|
+
# process swap the DO/MCP endpoint to an attacker-controlled URL) is
|
|
253
|
+
# gated behind ``ALTER_RUNTIME_DEV=1``.
|
|
254
|
+
# 3. A ``require_frame_signature`` placeholder flag is reserved so the
|
|
255
|
+
# follow-up PR that wires Ed25519 can flip it on without a config shape
|
|
256
|
+
# change.
|
|
257
|
+
#
|
|
258
|
+
# This block is deliberately small and documented so it can be ripped out
|
|
259
|
+
# when the proper frame-signature verification lands.
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class ConfigError(RuntimeError):
|
|
263
|
+
"""Raised when the loaded configuration fails a hardening invariant.
|
|
264
|
+
|
|
265
|
+
Never swallowed - if the daemon is misconfigured in a way that would
|
|
266
|
+
weaken the prompt-injection chain, we refuse to start. Louder than a log
|
|
267
|
+
line at this stage of the lifecycle.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _require_https(name: str, value: str) -> None:
|
|
272
|
+
"""Reject any non-``https://`` endpoint at config load."""
|
|
273
|
+
if not isinstance(value, str) or not value:
|
|
274
|
+
raise ConfigError(f"{name} must be a non-empty https:// URL (got {value!r})")
|
|
275
|
+
# Normalise only for validation; the original string is kept for the
|
|
276
|
+
# httpx client so any path templating (``{handle}``) still works.
|
|
277
|
+
lowered = value.lower()
|
|
278
|
+
if lowered.startswith("http://"):
|
|
279
|
+
raise ConfigError(
|
|
280
|
+
f"{name}={value!r} uses http:// - TLS is mandatory. "
|
|
281
|
+
"Set the endpoint to an https:// URL or leave the default."
|
|
282
|
+
)
|
|
283
|
+
if not lowered.startswith("https://"):
|
|
284
|
+
raise ConfigError(
|
|
285
|
+
f"{name}={value!r} is not an https:// URL - only https:// endpoints are accepted."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass
|
|
290
|
+
class DaemonConfig:
|
|
291
|
+
"""Runtime daemon configuration.
|
|
292
|
+
|
|
293
|
+
Defaults target the shipped L1 CF Worker endpoint and the backend MCP
|
|
294
|
+
fallback path per D-RT9.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
# L1 primary - per-handle DO SSE endpoint template (handle is substituted at runtime)
|
|
298
|
+
do_sse_endpoint: str = "https://mcp.truealter.com/events/{handle}/stream"
|
|
299
|
+
do_state_endpoint: str = "https://mcp.truealter.com/events/{handle}/state"
|
|
300
|
+
do_ingest_endpoint: str = "https://mcp.truealter.com/events/{handle}/ingest"
|
|
301
|
+
|
|
302
|
+
# Fallback - direct MCP polling per D-RT9.
|
|
303
|
+
#
|
|
304
|
+
# ``fallback_trigger_after_seconds`` doubles as the SSE chunk read timeout
|
|
305
|
+
# in :class:`DoSseSubscriber._consume_stream`. The handle-alter Worker
|
|
306
|
+
# emits SSE keepalive comments every 25s
|
|
307
|
+
# (``cloudflare/workers/handle-alter/src/sse.ts:SSE_PING_INTERVAL_MS``),
|
|
308
|
+
# so this value MUST exceed the keepalive interval - otherwise the stall
|
|
309
|
+
# watchdog fires before any keepalive arrives, every connection is treated
|
|
310
|
+
# as stalled, and the daemon enters a tight reconnect loop (~4s/cycle =
|
|
311
|
+
# ~21,600 reconnects/day per handle, observed 2026-04-29 as 139k/day across
|
|
312
|
+
# ~blake + ~drew). 75s = 3× keepalive, absorbing one missed ping plus
|
|
313
|
+
# clock skew. If the SSE_PING_INTERVAL_MS server constant is changed,
|
|
314
|
+
# this floor must be re-derived.
|
|
315
|
+
mcp_fallback_endpoint: str = "https://api.truealter.com/api/v1/mcp"
|
|
316
|
+
fallback_poll_interval_seconds: float = 30.0
|
|
317
|
+
fallback_trigger_after_seconds: float = 75.0
|
|
318
|
+
|
|
319
|
+
# Cross-host session presence (session_presence intent + /queries/presence
|
|
320
|
+
# projection). The daemon polls on a cadence and writes a sessions.json
|
|
321
|
+
# cache file the alter monorepo's bash awareness hook reads. The writer
|
|
322
|
+
# path is on the alter monorepo (cc-broadcast.sh emits start/heartbeat,
|
|
323
|
+
# session-summary.sh emits stop). Cap-minted with the alter_org.read
|
|
324
|
+
# scope on each cycle.
|
|
325
|
+
org_alter_caps_endpoint: str = "https://api.truealter.com/api/v1/org-alter/caps"
|
|
326
|
+
org_alter_presence_endpoint: str = "https://mcp.truealter.com/orgs/alter/queries/presence"
|
|
327
|
+
session_presence_enabled: bool = True
|
|
328
|
+
session_presence_poll_interval_seconds: float = 30.0
|
|
329
|
+
|
|
330
|
+
# --- D-COORD-D2 Wave C: active-sessions DO publisher -----------------
|
|
331
|
+
#: Base URL for the per-``~handle`` Cloudflare DO Worker. The publisher
|
|
332
|
+
#: POSTs each new envelope to
|
|
333
|
+
#: ``{do_publish_url}/events/{handle}/sessions/ingest`` where
|
|
334
|
+
#: ``{handle}`` is read from the envelope payload. Trailing slashes are
|
|
335
|
+
#: stripped. Override via ``ALTER_RUNTIME_DO_PUBLISH_URL``.
|
|
336
|
+
do_publish_url: str = "https://mcp.truealter.com"
|
|
337
|
+
#: When ``True``, register :class:`ActiveSessionsDoPublisher` as a
|
|
338
|
+
#: supervised Component. Default ``True`` so the daemon ships the wire
|
|
339
|
+
#: publishing on by default; disable via
|
|
340
|
+
#: ``ALTER_RUNTIME_DO_PUBLISH_ENABLED=0`` on hosts that intentionally
|
|
341
|
+
#: run local-only. The publisher mints a cap-JWT scoped
|
|
342
|
+
#: ``alter_events.sessions.ingest`` via the backend cap-mint endpoint
|
|
343
|
+
#: using the alter-cli session.jwt - no static service-token env
|
|
344
|
+
#: variable; the credential follows the live session.
|
|
345
|
+
do_publish_enabled: bool = True
|
|
346
|
+
#: Tick interval for the publisher's tail loop. 5 s matches the rough
|
|
347
|
+
#: cadence at which CC hooks emit heartbeats, so cross-host visibility
|
|
348
|
+
#: lag stays roughly one tick + one DO round-trip. Override via
|
|
349
|
+
#: ``ALTER_RUNTIME_DO_PUBLISH_POLL_INTERVAL_SECONDS``.
|
|
350
|
+
do_publish_poll_interval_seconds: float = 5.0
|
|
351
|
+
|
|
352
|
+
# --- Active-sessions GC pass (D-COORD-D2 Wave B) ----------------------
|
|
353
|
+
#: Master toggle for the periodic GC sweep over the active-sessions
|
|
354
|
+
#: JSONL. When disabled the daemon never emits idle/terminated
|
|
355
|
+
#: transitions on its own - relies solely on tool-side
|
|
356
|
+
#: ``session_ended`` emissions.
|
|
357
|
+
active_sessions_gc_enabled: bool = True
|
|
358
|
+
#: Cadence of the GC tick. 60s is well below the default idle
|
|
359
|
+
#: threshold so a session moves from ``active`` to ``idle`` within
|
|
360
|
+
#: roughly one tick of crossing the boundary.
|
|
361
|
+
active_sessions_gc_interval_seconds: float = 60.0
|
|
362
|
+
#: A session whose ``last_activity`` is older than this is folded
|
|
363
|
+
#: from ``active`` to ``idle``. 5 minutes matches the existing
|
|
364
|
+
#: heartbeat cadence ceiling.
|
|
365
|
+
active_sessions_idle_after_seconds: float = 300.0
|
|
366
|
+
#: A session whose ``last_activity`` is older than this is folded
|
|
367
|
+
#: to ``complete``. For ``tool == "cc"`` the PID is additionally
|
|
368
|
+
#: probed via ``os.kill(pid, 0)`` - only dead PIDs are reaped.
|
|
369
|
+
active_sessions_terminated_after_seconds: float = 900.0
|
|
370
|
+
|
|
371
|
+
# Socket server
|
|
372
|
+
unix_socket: Path = field(default_factory=unix_socket_path)
|
|
373
|
+
enable_dbus: bool = sys.platform.startswith("linux")
|
|
374
|
+
|
|
375
|
+
# Cache
|
|
376
|
+
cache_ttl_seconds: int = 300 # matches scripts/alter-identity.sh CACHE_TTL
|
|
377
|
+
|
|
378
|
+
# Telemetry
|
|
379
|
+
log_level: str = "INFO"
|
|
380
|
+
|
|
381
|
+
# --- Claude Code JSONL token-usage watcher ---------------------------
|
|
382
|
+
#: When ``True``, register :class:`ClaudeJsonlWatcher` as a supervised
|
|
383
|
+
#: Component. Opt-in (default ``False``) because not every principal's
|
|
384
|
+
#: machine needs ops token-usage telemetry, and the watcher requires a
|
|
385
|
+
#: valid session JWT to POST events to the backend. Enable via env var
|
|
386
|
+
#: ``ALTER_RUNTIME_CLAUDE_JSONL_WATCHER=1`` or direct config mutation.
|
|
387
|
+
enable_claude_jsonl_watcher: bool = False
|
|
388
|
+
|
|
389
|
+
# --- Hardening (C-1 / C-7 frame-signature enforcement) --------------
|
|
390
|
+
#: When ``True``, every SSE frame received by :class:`DoSseSubscriber`
|
|
391
|
+
#: MUST carry a valid Ed25519 signature over the canonical (JCS-style
|
|
392
|
+
#: stable-key-sorted) JSON bytes of the payload with the ``signature``
|
|
393
|
+
#: field removed. Frames that are unsigned, carry a malformed signature,
|
|
394
|
+
#: declare a ``pubkey`` that differs from the pinned
|
|
395
|
+
#: :attr:`frame_signature_pubkey`, or fail Ed25519 verification are
|
|
396
|
+
#: dropped with a structured warning log; counters on the subscriber's
|
|
397
|
+
#: :class:`_ConnectionState` record each drop class.
|
|
398
|
+
#:
|
|
399
|
+
#: Default is ``True`` post-pentest 2026-04-26 - operators must now
|
|
400
|
+
#: deliberately disable enforcement (``ALTER_RUNTIME_REQUIRE_FRAME_SIG=0``)
|
|
401
|
+
#: rather than silently inheriting the migration-window pass-through. The
|
|
402
|
+
#: subscriber refuses to construct when enforcement is on without a pinned
|
|
403
|
+
#: pubkey (see C-1 kill-switch in ``do_sse.py``), so flipping the default
|
|
404
|
+
#: to ``True`` forces operators to either provide
|
|
405
|
+
#: ``ALTER_RUNTIME_FRAME_SIG_PUBKEY`` or explicitly opt out - preferable
|
|
406
|
+
#: to the previous failure mode where unsigned frames were accepted by
|
|
407
|
+
#: default if the operator forgot to flip the flag at deploy time.
|
|
408
|
+
#:
|
|
409
|
+
#: Disable via ``ALTER_RUNTIME_REQUIRE_FRAME_SIG=0`` (only valid during
|
|
410
|
+
#: the DO-side signer migration window).
|
|
411
|
+
require_frame_signature: bool = True
|
|
412
|
+
|
|
413
|
+
#: Pinned Sovereign-tier Ed25519 public key for the DO emitting the SSE
|
|
414
|
+
#: stream. Expected format: ``ed25519:<base64url>`` matching the
|
|
415
|
+
#: canonical-fragment envelope convention. When
|
|
416
|
+
#: :attr:`require_frame_signature` is ``True`` this MUST be set - the
|
|
417
|
+
#: subscriber refuses to construct otherwise (see C-7 kill-switch
|
|
418
|
+
#: pattern) to prevent self-certifying-frame attacks where a frame's
|
|
419
|
+
#: declared pubkey is trusted solely on its own authority.
|
|
420
|
+
#:
|
|
421
|
+
#: Override via ``ALTER_RUNTIME_FRAME_SIG_PUBKEY``.
|
|
422
|
+
#:
|
|
423
|
+
#: Follow-up: the durable source of truth is the DO's ``/state``
|
|
424
|
+
#: endpoint (returns the current envelope with epoch-rotating pubkey);
|
|
425
|
+
#: a future PR fetches it on subscriber start-up and pins dynamically.
|
|
426
|
+
#: Static env-var pin is the interim trust root.
|
|
427
|
+
frame_signature_pubkey: str | None = None
|
|
428
|
+
|
|
429
|
+
# --- D-AUTOUPDATE-1 daemon update loop (Phase 1 observation only) -----
|
|
430
|
+
#: Master toggle for the auto-update poll loop. Default ``True`` per
|
|
431
|
+
#: D-AUTOUPDATE-1 — install-once-never-think-about-it is the shipped
|
|
432
|
+
#: posture. Operators flip to ``False`` (``ALTER_RUNTIME_AUTOUPDATE=0``)
|
|
433
|
+
#: when they want zero outbound polling — e.g. air-gapped fleets, or
|
|
434
|
+
#: integration tests that don't want a phantom network call.
|
|
435
|
+
#:
|
|
436
|
+
#: Phase 1 scope: the loop only polls + logs. Download, signature
|
|
437
|
+
#: verification, and atomic-replace land in Phase 2+. Disabling at
|
|
438
|
+
#: Phase 1 saves a periodic HTTPS round-trip to releases.truealter.com.
|
|
439
|
+
autoupdate_enabled: bool = True
|
|
440
|
+
#: Override the manifest URL. Defaults to the production substrate. Used
|
|
441
|
+
#: by integration tests to point at ``releases-staging.truealter.com``
|
|
442
|
+
#: once that ingest path exists. Override via
|
|
443
|
+
#: ``ALTER_RUNTIME_AUTOUPDATE_MANIFEST_URL``.
|
|
444
|
+
autoupdate_manifest_url: str = "https://releases.truealter.com/manifest.json"
|
|
445
|
+
#: Poll cadence in seconds. 24h matches DR §2 (every-UTC-midnight ± jitter).
|
|
446
|
+
#: Lower in dev to exercise the loop quickly. Override via
|
|
447
|
+
#: ``ALTER_RUNTIME_AUTOUPDATE_POLL_INTERVAL_SECONDS``.
|
|
448
|
+
autoupdate_poll_interval_seconds: int = 24 * 60 * 60
|
|
449
|
+
|
|
450
|
+
@classmethod
|
|
451
|
+
def load(cls) -> DaemonConfig:
|
|
452
|
+
"""Load config from environment + YAML file + defaults.
|
|
453
|
+
|
|
454
|
+
Raises
|
|
455
|
+
------
|
|
456
|
+
ConfigError
|
|
457
|
+
If any endpoint is non-``https://`` after env/YAML overrides,
|
|
458
|
+
or if the YAML override is present without ``ALTER_RUNTIME_DEV=1``.
|
|
459
|
+
"""
|
|
460
|
+
config = cls()
|
|
461
|
+
# Environment overrides
|
|
462
|
+
if endpoint := os.environ.get("ALTER_DO_SSE_ENDPOINT"):
|
|
463
|
+
config.do_sse_endpoint = endpoint
|
|
464
|
+
if endpoint := os.environ.get("ALTER_DO_STATE_ENDPOINT"):
|
|
465
|
+
config.do_state_endpoint = endpoint
|
|
466
|
+
if endpoint := os.environ.get("ALTER_DO_INGEST_ENDPOINT"):
|
|
467
|
+
config.do_ingest_endpoint = endpoint
|
|
468
|
+
if endpoint := os.environ.get("ALTER_MCP_FALLBACK_ENDPOINT"):
|
|
469
|
+
config.mcp_fallback_endpoint = endpoint
|
|
470
|
+
if endpoint := os.environ.get("ALTER_ORG_ALTER_CAPS_ENDPOINT"):
|
|
471
|
+
config.org_alter_caps_endpoint = endpoint
|
|
472
|
+
if endpoint := os.environ.get("ALTER_ORG_ALTER_PRESENCE_ENDPOINT"):
|
|
473
|
+
config.org_alter_presence_endpoint = endpoint
|
|
474
|
+
_sp_flag = os.environ.get("ALTER_SESSION_PRESENCE_ENABLED", "").lower()
|
|
475
|
+
if _sp_flag in ("0", "false", "no", "off"):
|
|
476
|
+
config.session_presence_enabled = False
|
|
477
|
+
elif _sp_flag in ("1", "true", "yes", "on"):
|
|
478
|
+
config.session_presence_enabled = True
|
|
479
|
+
if interval := os.environ.get("ALTER_SESSION_PRESENCE_POLL_INTERVAL_SECONDS"):
|
|
480
|
+
try:
|
|
481
|
+
config.session_presence_poll_interval_seconds = float(interval)
|
|
482
|
+
except ValueError:
|
|
483
|
+
pass # Ignore malformed override; default stands.
|
|
484
|
+
|
|
485
|
+
# --- D-COORD-D2 Wave C: active-sessions DO publisher -------------
|
|
486
|
+
if publish_url := os.environ.get("ALTER_RUNTIME_DO_PUBLISH_URL"):
|
|
487
|
+
config.do_publish_url = publish_url
|
|
488
|
+
_dpe_flag = os.environ.get("ALTER_RUNTIME_DO_PUBLISH_ENABLED", "").lower()
|
|
489
|
+
if _dpe_flag in ("0", "false", "no", "off"):
|
|
490
|
+
config.do_publish_enabled = False
|
|
491
|
+
elif _dpe_flag in ("1", "true", "yes", "on"):
|
|
492
|
+
config.do_publish_enabled = True
|
|
493
|
+
if dpi := os.environ.get("ALTER_RUNTIME_DO_PUBLISH_POLL_INTERVAL_SECONDS"):
|
|
494
|
+
try:
|
|
495
|
+
config.do_publish_poll_interval_seconds = float(dpi)
|
|
496
|
+
except ValueError:
|
|
497
|
+
pass
|
|
498
|
+
|
|
499
|
+
# --- Active-sessions GC pass (D-COORD-D2 Wave B) -----------------
|
|
500
|
+
_gc_flag = os.environ.get("ALTER_ACTIVE_SESSIONS_GC_ENABLED", "").lower()
|
|
501
|
+
if _gc_flag in ("0", "false", "no", "off"):
|
|
502
|
+
config.active_sessions_gc_enabled = False
|
|
503
|
+
elif _gc_flag in ("1", "true", "yes", "on"):
|
|
504
|
+
config.active_sessions_gc_enabled = True
|
|
505
|
+
if gc_interval := os.environ.get("ALTER_ACTIVE_SESSIONS_GC_INTERVAL_SECONDS"):
|
|
506
|
+
try:
|
|
507
|
+
config.active_sessions_gc_interval_seconds = float(gc_interval)
|
|
508
|
+
except ValueError:
|
|
509
|
+
pass # Ignore malformed override; default stands.
|
|
510
|
+
if idle_after := os.environ.get("ALTER_ACTIVE_SESSIONS_IDLE_AFTER_SECONDS"):
|
|
511
|
+
try:
|
|
512
|
+
config.active_sessions_idle_after_seconds = float(idle_after)
|
|
513
|
+
except ValueError:
|
|
514
|
+
pass
|
|
515
|
+
if terminated_after := os.environ.get("ALTER_ACTIVE_SESSIONS_TERMINATED_AFTER_SECONDS"):
|
|
516
|
+
try:
|
|
517
|
+
config.active_sessions_terminated_after_seconds = float(terminated_after)
|
|
518
|
+
except ValueError:
|
|
519
|
+
pass
|
|
520
|
+
|
|
521
|
+
# --- D-AUTOUPDATE-1 daemon update loop ---------------------------
|
|
522
|
+
_au_flag = os.environ.get("ALTER_RUNTIME_AUTOUPDATE", "").lower()
|
|
523
|
+
if _au_flag in ("0", "false", "no", "off"):
|
|
524
|
+
config.autoupdate_enabled = False
|
|
525
|
+
elif _au_flag in ("1", "true", "yes", "on"):
|
|
526
|
+
config.autoupdate_enabled = True
|
|
527
|
+
if au_url := os.environ.get("ALTER_RUNTIME_AUTOUPDATE_MANIFEST_URL"):
|
|
528
|
+
config.autoupdate_manifest_url = au_url
|
|
529
|
+
if au_interval := os.environ.get("ALTER_RUNTIME_AUTOUPDATE_POLL_INTERVAL_SECONDS"):
|
|
530
|
+
try:
|
|
531
|
+
config.autoupdate_poll_interval_seconds = int(au_interval)
|
|
532
|
+
except ValueError:
|
|
533
|
+
pass
|
|
534
|
+
|
|
535
|
+
if level := os.environ.get("ALTER_RUNTIME_LOG_LEVEL"):
|
|
536
|
+
config.log_level = level
|
|
537
|
+
if socket := os.environ.get("ALTER_RUNTIME_SOCKET"):
|
|
538
|
+
config.unix_socket = Path(socket).expanduser()
|
|
539
|
+
_cjw_flag = os.environ.get("ALTER_RUNTIME_CLAUDE_JSONL_WATCHER", "").lower()
|
|
540
|
+
if _cjw_flag in ("1", "true", "yes", "on"):
|
|
541
|
+
config.enable_claude_jsonl_watcher = True
|
|
542
|
+
elif _cjw_flag in ("0", "false", "no", "off"):
|
|
543
|
+
config.enable_claude_jsonl_watcher = False
|
|
544
|
+
# Post-pentest-2026-04-26: default is True. Env var still toggles -
|
|
545
|
+
# explicit "0"/"false"/"no"/"off" disables (migration-window opt-out),
|
|
546
|
+
# everything else (including unset) keeps the default True.
|
|
547
|
+
#
|
|
548
|
+
# Pentest S3-W-1 production-lock (2026-05-09): the opt-out is only
|
|
549
|
+
# valid when ALTER_RUNTIME_DEV=1 is also set. In any non-dev
|
|
550
|
+
# environment (ALTER_RUNTIME_DEV unset or not "1"/"true"/"yes") the
|
|
551
|
+
# =0 value is silently overridden back to True and a structured
|
|
552
|
+
# warning is emitted. This prevents a compromised or misconfigured
|
|
553
|
+
# env from disabling frame-signature enforcement in production.
|
|
554
|
+
_dev_mode = os.environ.get("ALTER_RUNTIME_DEV", "").lower() in (
|
|
555
|
+
"1",
|
|
556
|
+
"true",
|
|
557
|
+
"yes",
|
|
558
|
+
)
|
|
559
|
+
_flag = os.environ.get("ALTER_RUNTIME_REQUIRE_FRAME_SIG", "").lower()
|
|
560
|
+
if _flag in ("0", "false", "no", "off"):
|
|
561
|
+
if _dev_mode:
|
|
562
|
+
config.require_frame_signature = False
|
|
563
|
+
else:
|
|
564
|
+
# Production-lock: refuse the opt-out outside of dev mode.
|
|
565
|
+
import logging as _logging
|
|
566
|
+
|
|
567
|
+
_logging.getLogger(__name__).warning(
|
|
568
|
+
"ALTER_RUNTIME_REQUIRE_FRAME_SIG=0 ignored - opt-out is "
|
|
569
|
+
"only permitted when ALTER_RUNTIME_DEV=1 is set. "
|
|
570
|
+
"Frame-signature enforcement remains ENABLED. "
|
|
571
|
+
"If this is a migration-window deployment, set "
|
|
572
|
+
"ALTER_RUNTIME_DEV=1 explicitly."
|
|
573
|
+
)
|
|
574
|
+
config.require_frame_signature = True
|
|
575
|
+
elif _flag in ("1", "true", "yes", "on"):
|
|
576
|
+
config.require_frame_signature = True
|
|
577
|
+
if pubkey := os.environ.get("ALTER_RUNTIME_FRAME_SIG_PUBKEY"):
|
|
578
|
+
config.frame_signature_pubkey = pubkey
|
|
579
|
+
|
|
580
|
+
# YAML file overrides - gated behind ALTER_RUNTIME_DEV=1 (C-7 / M-J).
|
|
581
|
+
# Previously any local process could drop a runtime.yaml onto disk
|
|
582
|
+
# and swap do_sse_endpoint to an attacker-controlled URL, which fed
|
|
583
|
+
# unverified events into every CC session's system message. The
|
|
584
|
+
# dev-only gate closes that path for shipped builds until the
|
|
585
|
+
# Ed25519-signed config file format lands.
|
|
586
|
+
yaml_path = config_dir() / "runtime.yaml"
|
|
587
|
+
if yaml_path.exists():
|
|
588
|
+
dev_mode = os.environ.get("ALTER_RUNTIME_DEV", "").lower() in (
|
|
589
|
+
"1",
|
|
590
|
+
"true",
|
|
591
|
+
"yes",
|
|
592
|
+
)
|
|
593
|
+
if not dev_mode:
|
|
594
|
+
raise ConfigError(
|
|
595
|
+
f"{yaml_path} present but ALTER_RUNTIME_DEV is not set. "
|
|
596
|
+
"File-based endpoint overrides are dev-only pre-T-0 - "
|
|
597
|
+
"remove the file, set endpoints via ALTER_*_ENDPOINT env "
|
|
598
|
+
"vars, or export ALTER_RUNTIME_DEV=1 explicitly."
|
|
599
|
+
)
|
|
600
|
+
try:
|
|
601
|
+
import yaml # imported lazily so tests without PyYAML still run
|
|
602
|
+
|
|
603
|
+
with yaml_path.open("r") as f:
|
|
604
|
+
data = yaml.safe_load(f) or {}
|
|
605
|
+
for key, value in data.items():
|
|
606
|
+
if hasattr(config, key):
|
|
607
|
+
setattr(config, key, value)
|
|
608
|
+
except ImportError:
|
|
609
|
+
# PyYAML not installed - surface loudly in dev mode so the
|
|
610
|
+
# operator knows their override was ignored.
|
|
611
|
+
raise
|
|
612
|
+
except OSError:
|
|
613
|
+
# Readable-but-broken YAML is swallowed (same as pre-harden
|
|
614
|
+
# behaviour) - avoids a disk-error crash loop in dev.
|
|
615
|
+
pass
|
|
616
|
+
|
|
617
|
+
# --- Post-override invariant checks -----------------------------
|
|
618
|
+
_require_https("do_sse_endpoint", config.do_sse_endpoint)
|
|
619
|
+
_require_https("do_state_endpoint", config.do_state_endpoint)
|
|
620
|
+
_require_https("do_ingest_endpoint", config.do_ingest_endpoint)
|
|
621
|
+
_require_https("mcp_fallback_endpoint", config.mcp_fallback_endpoint)
|
|
622
|
+
_require_https("org_alter_caps_endpoint", config.org_alter_caps_endpoint)
|
|
623
|
+
_require_https("org_alter_presence_endpoint", config.org_alter_presence_endpoint)
|
|
624
|
+
_require_https("do_publish_url", config.do_publish_url)
|
|
625
|
+
|
|
626
|
+
return config
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def load_config() -> DaemonConfig:
|
|
630
|
+
"""Public entrypoint - load daemon configuration."""
|
|
631
|
+
return DaemonConfig.load()
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ---------------------------------------------------------------------------
|
|
635
|
+
# Helpers
|
|
636
|
+
# ---------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def ensure_directories() -> None:
|
|
640
|
+
"""Create required XDG directories if they don't exist.
|
|
641
|
+
|
|
642
|
+
Called by `alter-runtime init` and at daemon startup so that subsequent
|
|
643
|
+
writes don't fail with FileNotFoundError.
|
|
644
|
+
"""
|
|
645
|
+
for d in (config_dir(), cache_dir(), state_dir()):
|
|
646
|
+
d.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
647
|
+
socket_path = unix_socket_path()
|
|
648
|
+
socket_path.parent.mkdir(parents=True, exist_ok=True)
|