Ambient system companions over one privacy-preserving signal daemon (aggregate-only, no keystroke content): a git-driven terminal garden and IOKit hardware collectors. ambient daemon macos privacy terminal

Commit 42a6dcc60e

42a6dcc60efc50dfdc8a93ad9061f19180c66695

parent: b5aad2e5f9

Verified · cmc ci/test: error

cmc <hello@cleberg.net> · 2026-09-04 04:04 UTC

Ingest macos-collector frames in signald

Replace the system_hw_tick stub with collectors::hardware: signald spawns
macos-collector as a child (--collector <path>, or found on PATH) and reads
its stdout with the same wire::read_frame the socket uses, publishing each
frame into the hub. The five hardware signals now reach the history store and
every subscriber. Without the binary signald logs that and runs without them.

Self-attestation reports the hardware collector path or why it is disabled.

tests/hardware_ingest.rs feeds canned frames through the ingest path and
checks a subscriber's snapshot, and that a frame breaking the tag rule is
rejected at the boundary.

Closes #1
README.md +18 −9
@@ -96,12 +96,15 @@ agent. Signals come from IOKit (`IOPMPowerSource` / power sources, IORegistry
9696metric can't be reached without root, it is simply **absent from the schema**
9797rather than gated behind sudo. Accessibility permission is never requested.
9898
99It ships in v0.3 as **`macos-collector/`**, a sibling **Swift** package (built
100with `swift build`, kept out of the cargo workspace — SwiftPM and cargo do not
101share a build system). It reads aggregate CPU load, battery %, charging, battery
102draw (W), and thermal state, and emits them as `signal-schema` wire frames — the
103same byte format `signald` parses. GPU/fan are deliberately omitted (no clean
104root-free IOKit channel). See `macos-collector/README.md` for the full **Swift ↔
99It ships as **`macos-collector/`**, a sibling **Swift** package (built with
100`swift build`, kept out of the cargo workspace — SwiftPM and cargo do not share
101a build system). It reads aggregate CPU load, battery %, charging, battery draw
102(W), and thermal state, and emits them as `signal-schema` wire frames — the
103same byte format `signald` parses. `signald` spawns it as a child process
104(found on `PATH`, or named with `--collector <path>`) and ingests the frames it
105writes to stdout, so hardware signals reach the hub, the history store, and
106every subscriber by the same path as git and terminal signals. GPU/fan are
107deliberately omitted (no clean root-free IOKit channel). See `macos-collector/README.md` for the full **Swift ↔
105108Rust wire contract** (the byte layout) and the shared canonical-frame test that
106109pins both sides to the same bytes.
107110
@@ -115,12 +118,13 @@ ambient-companions/
115118│ │ ├── src/lib.rs
116119│ │ └── tests/privacy_invariant.rs
117120│ ├── signald/ # the daemon (collectors + live fan-out)
118│ │ ├── src/lib.rs # git + terminal collectors, publish
121│ │ ├── src/lib.rs # git + terminal collectors, hardware ingest, publish
119122│ │ ├── src/history.rs # SQLite (WAL) history store + recent() query
120123│ │ ├── src/hub.rs # last-value cache + live fan-out
121124│ │ ├── src/main.rs # CLI, producer loop, self-attestation
122125│ │ ├── tests/git_collector.rs # aggregates vs a temp git repo
123126│ │ ├── tests/streaming.rs # last-value cache + live update
127│ │ ├── tests/hardware_ingest.rs # collector frames reach a subscriber
124128│ │ └── tests/differential_secret_typing.rs # the full privacy ship-gate
125129│ └── terminal-garden/ # first renderer: a socket subscriber
126130│ ├── src/lib.rs # signals → plots → render (unit-tested)
@@ -193,8 +197,13 @@ and — for the differential test — `zpty` modules ship with zsh).
193197 documented and pinned by a shared canonical-frame test on both sides
194198 (`crates/signal-schema/tests/hardware_wire.rs` decodes the exact bytes the
195199 Swift encoder commits to in `macos-collector/Tests/.../WireTests.swift`).
196 *Still todo:* the menu-bar permadeath pet, and the live socket handshake by
197 which `signald` ingests the collector's frames (minimal/documented in v0.3).
200 *Still todo:* the menu-bar permadeath pet.
201- **Hardware ingest. ✅ Done (v0.4).**
202 `signald` spawns `macos-collector` as a child (`--collector <path>`, or found
203 on `PATH`) and reads its stdout with the same `wire::read_frame` the socket
204 uses (`collectors::hardware`). The five hardware signals now appear in the
205 hub, the history store, and every subscriber's snapshot
206 (`crates/signald/tests/hardware_ingest.rs`).
198207- **Phase 4** — sonification (SSH-utility first, then continuous). *Out of scope.*
199208- **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse.
200209 *Out of scope.*
crates/signald/src/lib.rs +63 −11
@@ -9,7 +9,8 @@
99//! **live pub/sub bus with a last-value cache** ([`hub`], [`publish`]). The
1010//! sensitive terminal collector is aggregate-only and ships behind the now-active
1111//! differential secret-typing test (see `signal-schema/tests/privacy_invariant.rs`).
12//! The system/hardware collector remains a stub (spec phase 3).
12//! v0.4 adds the **hardware path**: the daemon spawns the sibling
13//! `macos-collector` and ingests its wire frames ([`collectors::hardware`]).
1314
1415use std::time::{SystemTime, UNIX_EPOCH};
1516
@@ -205,19 +206,70 @@ pub mod collectors {
205206 }
206207 }
207208
208 use signal_schema::Signal;
209
210209 /// System + hardware collector: **IOKit-only, no powermetrics, no root**
211210 /// (spec §1.4). Native macOS.
212211 ///
213 /// v0.3: the real collector ships out-of-process as the sibling Swift package
214 /// `macos-collector/` (SwiftPM, not in this cargo workspace), which reads
215 /// aggregate hardware scalars via IOKit and emits `signal-schema` wire frames
216 /// this daemon can parse (see `signal-schema/tests/hardware_wire.rs`). This
217 /// in-process hook stays a stub: signald's live frame-ingest handshake for
218 /// those frames is the next increment.
219 pub fn system_hw_tick() -> Vec<Signal> {
220 todo!("v0.3: hardware signals come from the sibling macos-collector Swift package")
212 /// The reads live out of process in the sibling Swift package
213 /// `macos-collector/` (SwiftPM, not in this cargo workspace), which writes
214 /// `signal-schema` wire frames to stdout. This module spawns it and publishes
215 /// every frame it emits, so hardware signals reach the hub, the history
216 /// store, and subscribers by the same path as every other collector. Frames
217 /// are decoded by the same `wire::read_frame` the socket uses, so one that
218 /// fails the schema's structural checks (version, tag rule) is rejected at
219 /// this boundary.
220 pub mod hardware {
221 use std::io::{self, Read};
222 use std::path::{Path, PathBuf};
223 use std::process::{Child, Command, Stdio};
224 use std::thread;
225
226 use signal_schema::wire;
227
228 use crate::hub::Hub;
229
230 /// The collector binary's name, looked up on `PATH` when no explicit
231 /// path is configured.
232 pub const PROGRAM: &str = "macos-collector";
233
234 /// Read frames from `reader` until EOF, publishing each into `hub`.
235 /// Returns the number of frames published. A malformed frame ends the
236 /// stream with an error.
237 pub fn ingest(reader: &mut impl Read, hub: &Hub) -> io::Result<usize> {
238 let mut n = 0;
239 while let Some(sig) = wire::read_frame(reader)? {
240 hub.publish(sig);
241 n += 1;
242 }
243 Ok(n)
244 }
245
246 /// Spawn `program` streaming frames every `interval_ms` and ingest its
247 /// stdout on a background thread. Returns once the child is running;
248 /// the thread logs when the child's stream ends. The child's stdout is
249 /// a pipe, so it exits on its next write after the daemon goes away.
250 pub fn spawn(program: &Path, interval_ms: u64, hub: Hub) -> io::Result<Child> {
251 let mut child = Command::new(program)
252 .arg("--interval-ms")
253 .arg(interval_ms.to_string())
254 .stdin(Stdio::null())
255 .stdout(Stdio::piped())
256 .stderr(Stdio::inherit())
257 .spawn()?;
258 let mut stdout = child.stdout.take().expect("stdout is piped");
259 thread::spawn(move || match ingest(&mut stdout, &hub) {
260 Ok(n) => eprintln!("signald: hardware collector exited after {n} frame(s)"),
261 Err(e) => eprintln!("signald: hardware collector stream error: {e}"),
262 });
263 Ok(child)
264 }
265
266 /// Find [`PROGRAM`] on `PATH`.
267 pub fn find_on_path() -> Option<PathBuf> {
268 let path = std::env::var_os("PATH")?;
269 std::env::split_paths(&path)
270 .map(|dir| dir.join(PROGRAM))
271 .find(|p| p.is_file())
272 }
221273 }
222274}
223275
crates/signald/src/main.rs +31 −7
@@ -7,12 +7,14 @@
77//! ```text
88//! zsh hooks ───▶ terminal collector ─┐ (aggregate counts from the spool)
99//! git/fsevents ▶ git collector ──────┼─▶ hub: last-value cache + fan-out
10//! IOKit/AppKit ▶ system+hw collector ┘ (stub, phase 3)
10//! IOKit/AppKit ▶ system+hw collector ┘ (macos-collector child, frames on stdout)
1111//! ├─▶ publish: Unix socket (live)
1212//! └─▶ SQLite WAL history
1313//! ```
1414//!
15//! v0.2: the git and terminal collectors are live; the hub caches the latest
15//! The git and terminal collectors run in-process on a tick; the hardware
16//! collector is the sibling `macos-collector` binary, spawned as a child whose
17//! stdout frames are ingested into the same hub. The hub caches the latest
1618//! value of every metric and streams updates to subscribers; every signal is
1719//! persisted to a SQLite (WAL) history store. Collector internals live in the
1820//! `signald` library crate; this binary is argument wiring, the producer loop,
@@ -21,11 +23,13 @@
2123//! Usage:
2224//! ```text
2325//! signald [--socket <path>] [--db <path>] [--spool <path>]
24//! [--interval-ms <n>] [<repo-path> ...]
26//! [--collector <path>] [--interval-ms <n>] [<repo-path> ...]
2527//! ```
2628//! With no repo paths, the current directory is watched. The socket defaults to
2729//! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the
28//! history db and terminal spool default alongside it.
30//! history db and terminal spool default alongside it. `--collector` names the
31//! `macos-collector` binary; by default it is looked up on `PATH` and skipped,
32//! with a log line, when absent.
2933
3034use std::path::PathBuf;
3135use std::thread;
@@ -41,13 +45,14 @@ struct Config {
4145 socket: PathBuf,
4246 db: PathBuf,
4347 spool: PathBuf,
48 collector: Option<PathBuf>,
4449 interval: Duration,
4550 repos: Vec<PathBuf>,
4651}
4752
4853fn main() {
49 print_self_attestation();
5054 let cfg = parse_args();
55 print_self_attestation(&cfg);
5156
5257 let history = match History::open(&cfg.db) {
5358 Ok(h) => {
@@ -61,6 +66,15 @@ fn main() {
6166 };
6267 let hub = Hub::with_history(history);
6368
69 // Hardware: spawn the out-of-process collector and ingest its frames. Not
70 // having one (Linux, or a dev build not on PATH) is not fatal.
71 if let Some(collector) = &cfg.collector {
72 match collectors::hardware::spawn(collector, cfg.interval.as_millis() as u64, hub.clone()) {
73 Ok(_) => eprintln!("signald: hardware collector {}", collector.display()),
74 Err(e) => eprintln!("signald: cannot start hardware collector {}: {e}", collector.display()),
75 }
76 }
77
6478 // Producer: poll the collectors on a tick and publish into the hub. Runs
6579 // for the life of the daemon, independent of any subscriber.
6680 let producer = hub.clone();
@@ -90,6 +104,7 @@ fn parse_args() -> Config {
90104 let mut socket: Option<PathBuf> = None;
91105 let mut db: Option<PathBuf> = None;
92106 let mut spool: Option<PathBuf> = None;
107 let mut collector: Option<PathBuf> = None;
93108 let mut interval_ms: u64 = 2000;
94109 let mut repos: Vec<PathBuf> = Vec::new();
95110
@@ -99,6 +114,7 @@ fn parse_args() -> Config {
99114 "--socket" => socket = args.next().map(PathBuf::from),
100115 "--db" => db = args.next().map(PathBuf::from),
101116 "--spool" => spool = args.next().map(PathBuf::from),
117 "--collector" => collector = args.next().map(PathBuf::from),
102118 "--interval-ms" => {
103119 interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms)
104120 }
@@ -114,6 +130,7 @@ fn parse_args() -> Config {
114130 Config {
115131 db: db.unwrap_or_else(|| base.join("signald.sqlite")),
116132 spool: spool.unwrap_or_else(|| base.join("terminal.spool")),
133 collector: collector.or_else(collectors::hardware::find_on_path),
117134 interval: Duration::from_millis(interval_ms),
118135 repos,
119136 socket,
@@ -132,15 +149,22 @@ fn default_socket_path() -> PathBuf {
132149/// Log enabled collectors and assert none holds an input-tap capability. A real
133150/// keylogger would need one of the forbidden APIs; their absence is the point,
134151/// and this is the runtime half of that guarantee (spec §1.5).
135fn print_self_attestation() {
152fn print_self_attestation(cfg: &Config) {
136153 eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
137154 eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub");
138155 eprintln!(" history: sqlite (WAL), aggregate scalars only");
156 let hardware = match &cfg.collector {
157 Some(p) => format!("ENABLED (out-of-process {}; IOKit only, no root)", p.display()),
158 None => format!(
159 "DISABLED ({} not on PATH; pass --collector <path>)",
160 collectors::hardware::PROGRAM
161 ),
162 };
139163 for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] {
140164 let state = match source {
141165 Source::Git => "ENABLED (aggregate scalars only)",
142166 Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)",
143 Source::Macos | Source::Hardware => "stub (phase 3)",
167 Source::Macos | Source::Hardware => hardware.as_str(),
144168 };
145169 eprintln!(" collector {source:?}: input-tap capability = NONE — {state}");
146170 }
crates/signald/tests/hardware_ingest.rs added +83
@@ -0,0 +1,83 @@
1//! Hardware ingest: frames written by the out-of-process collector are read by
2//! the same `wire::read_frame` the socket uses and published into the hub, so
3//! a subscriber's snapshot carries them like any other collector's signals.
4
5use std::io::Cursor;
6
7use signal_schema::{wire, Signal, SignalName, Source, Tag, Value, SCHEMA_VERSION};
8use signald::collectors::hardware;
9use signald::hub::Hub;
10
11fn sig(source: Source, name: SignalName, value: f64) -> Signal {
12 Signal {
13 schema_version: SCHEMA_VERSION,
14 ts: 1_723_100_000_000,
15 source,
16 name,
17 value: Value(value),
18 tag: None,
19 }
20}
21
22/// The five signals `macos-collector` emits per tick, as it emits them.
23fn one_tick() -> Vec<Signal> {
24 vec![
25 sig(Source::Hardware, SignalName::CpuLoad, 0.25),
26 sig(Source::Macos, SignalName::BatteryPct, 80.0),
27 sig(Source::Macos, SignalName::Charging, 1.0),
28 sig(Source::Hardware, SignalName::BatteryDrawW, 12.5),
29 sig(Source::Macos, SignalName::ThermalState, 0.0),
30 ]
31}
32
33fn frames(signals: &[Signal]) -> Vec<u8> {
34 let mut buf = Vec::new();
35 for s in signals {
36 wire::write_frame(&mut buf, s).unwrap();
37 }
38 buf
39}
40
41#[test]
42fn canned_frames_reach_a_subscriber_snapshot() {
43 let hub = Hub::new();
44 let mut first = one_tick();
45 let mut second = one_tick();
46 second[0].value = Value(0.75); // cpu_load changes on the second tick
47 first.append(&mut second);
48
49 let n = hardware::ingest(&mut Cursor::new(frames(&first)), &hub).unwrap();
50 assert_eq!(n, 10, "every frame is published");
51
52 let (snapshot, _rx) = hub.subscribe();
53 assert_eq!(snapshot.len(), 5, "one cached value per metric");
54 let cpu = snapshot.iter().find(|s| s.name == SignalName::CpuLoad).unwrap();
55 assert_eq!(cpu.value, Value(0.75), "keep-latest");
56 assert_eq!(cpu.source, Source::Hardware);
57 for name in [
58 SignalName::BatteryPct,
59 SignalName::Charging,
60 SignalName::BatteryDrawW,
61 SignalName::ThermalState,
62 ] {
63 assert!(snapshot.iter().any(|s| s.name == name), "{name:?} missing");
64 }
65}
66
67#[test]
68fn frame_that_breaks_the_tag_rule_is_rejected_at_the_boundary() {
69 let hub = Hub::new();
70 let good = sig(Source::Hardware, SignalName::CpuLoad, 0.5);
71 // CpuLoad never allows a tag. Encode one anyway, bypassing the schema's
72 // well-formedness check, as a misbehaving collector could.
73 let mut bad = good.clone();
74 bad.tag = Some(Tag::repo_path("/not/allowed").unwrap());
75 let mut buf = frames(&[good]);
76 buf.extend(wire::encode(&bad));
77
78 let err = hardware::ingest(&mut Cursor::new(buf), &hub).unwrap_err();
79 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
80 let snapshot = hub.snapshot();
81 assert_eq!(snapshot.len(), 1, "the good frame before it was published");
82 assert!(snapshot[0].tag.is_none());
83}
macos-collector/README.md +14 −9
@@ -97,12 +97,17 @@ execution is not required to prove they agree — the shared literal is the proo
9797(A live `swift run macos-collector --once --hex` produces frames that decode
9898byte-for-byte to this layout; see the project README for a captured run.)
9999
100## Wiring into signald (Phase 3 scope note)
101
102`signald`'s in-process `system_hw_tick` collector is a stub; this out-of-process
103Swift collector is its real realization. The **live socket handshake** by which
104`signald` ingests these frames is intentionally minimal in v0.3 (spec's stated
105Phase 3 scope): the collector emits frames to stdout / `--out` today, and the
106guarantee that `signald` parses them is the shared wire-contract test above
107(which exercises the identical `wire::decode` the socket path uses). A full
108cross-process ingest handshake is the next increment.
100## Wiring into signald
101
102`signald` spawns this binary as a child with `--interval-ms <n>` and reads the
103frames it writes to stdout (`collectors::hardware` in `crates/signald`), using
104the same `wire::read_frame` the socket path uses. It looks for
105`macos-collector` on `PATH`; a dev build is named explicitly:
106
107```sh
108signald --collector macos-collector/.build/debug/macos-collector
109```
110
111When the binary is absent (Linux, or not on `PATH`) signald logs that and runs
112without hardware signals. Stdout is a pipe, so the collector exits on its next
113write after signald goes away.
macos-collector/Sources/macos-collector/main.swift +3 −4
@@ -15,10 +15,9 @@ import CollectorCore
1515// --out <path> append raw frames to <path> (a spool the daemon can tail)
1616// instead of stdout
1717//
18// Wiring into signald: signald's live frame-ingest handshake is documented in
19// README.md and is intentionally minimal here (spec Phase 3 scope note). The
20// authoritative proof that signald parses these frames is the shared-byte
21// contract test on both sides (see WIRE section of the README).
18// Wiring into signald: signald spawns this binary as a child in the default
19// stream mode and reads the frames from its stdout. The shared-byte contract
20// test on both sides pins the format (see the WIRE section of the README).
2221
2322func fail(_ msg: String) -> Never {
2423 FileHandle.standardError.write(Data("macos-collector: \(msg)\n".utf8))