// Package ipc is maven's core↔module boundary. // // Core = the only key-holder: the daemon process holds the unlocked sqlcipher // db + the trigger loop. Modules (stt/tts, router/classifier, tool executors, // delivery) are separate processes — restart-free, key-free, fail-independent. // "a crashing tts can't read the key page" only holds if there IS a page // boundary between core and modules; this package IS that boundary. // // Transport: unix domain socket, local-only. The socket's filesystem perms // (0700 dir, 0600 socket) are the current auth floor — "same unix user" — // carrying the same instinct as wg-floor at the network radius. The full // 4-layer auth cascade (wg/mTLS/passkey/step-up) is a later module; IPC // threads a Caller (uid/pid via SO_PEERCRED) so the auth layer can scope // module authority without restructuring the wire. // // Core mediates, never hands back a db handle. The methods here are the only // state operations a module can perform: write a fact (provenance-scoped by // source), read a fact / presence, create/complete reminders, record/resolve // nudges. Anything needing raw db access lives in core and is unreachable. package ipc import ( "encoding/binary" "encoding/json" "errors" "fmt" "io" ) // maxFrame — 4 MiB. a single fact/reminder/nudge is tiny; this is a safety cap // against a confused peer sending a terabyte of length prefix, not a real // operational limit. away-channel bodies are minimal by spec. const maxFrame = 4 << 20 // ErrFrameTooLarge — a frame exceeded maxFrame; the conn is now desynchronized // (we read the length but not the body), so the caller must close it. var ErrFrameTooLarge = errors.New("ipc: frame too large") // writeFrame encodes v as JSON and frames it as a 4-byte big-endian length // prefix + body. length-prefixed JSON (not a tighter binary schema) is the // deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to // evolve while the protocol settles, and at single-user local scale the // encode cost is invisible next to a db round-trip. func writeFrame(w io.Writer, v any) error { body, err := json.Marshal(v) if err != nil { return fmt.Errorf("ipc: marshal frame: %w", err) } if len(body) > maxFrame { return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, len(body)) } var hdr [4]byte binary.BigEndian.PutUint32(hdr[:], uint32(len(body))) if _, err := w.Write(hdr[:]); err != nil { return fmt.Errorf("ipc: write frame header: %w", err) } if _, err := w.Write(body); err != nil { return fmt.Errorf("ipc: write frame body: %w", err) } return nil } // readFrame reads one length-prefixed frame into v. A zero-length frame is // legal JSON (e.g. `null`/`{}` encode to a few bytes, never zero) — we don't // treat it as EOF; only an EOF on the header read does. func readFrame(r io.Reader, v any) error { var hdr [4]byte if _, err := io.ReadFull(r, hdr[:]); err != nil { if errors.Is(err, io.EOF) { return io.EOF } return fmt.Errorf("ipc: read frame header: %w", err) } n := binary.BigEndian.Uint32(hdr[:]) if n > maxFrame { return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, n) } buf := make([]byte, n) if _, err := io.ReadFull(r, buf); err != nil { return fmt.Errorf("ipc: read frame body: %w", err) } if err := json.Unmarshal(buf, v); err != nil { return fmt.Errorf("ipc: unmarshal frame: %w", err) } return nil }