alter-runtime 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,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)