From a90d7b7079a0be0c9d265ffdd9153a7373413a01 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 4 Aug 2026 13:39:58 +0300 Subject: [PATCH 1/4] feat(kernel): gate the macOS Contacts cohort behind `contacts` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sheds `objc2`, `objc2-foundation`, `objc2-contacts` and `block2` — plus two transitives, 6 packages total — from slim macOS builds. All four are used exclusively by `memory::people::address_book`; verified by grepping every `::` path in src/ and finding no other file. Nearly free, because the off-state already existed: `address_book.rs` has shipped a non-macOS `imp` stub returning an empty contact list since before the gate. The change is to widen that stub's cfg from `not(target_os = "macos")` to `not(all(target_os = "macos", feature = "contacts"))`, and narrow the real one to match. So `read()`, `read_with()`, `AddressBookError` and `SystemContactsSource` stay compiled in every build, the `people` RPC surface is byte-identical, and an address-book refresh with the gate off seeds nothing rather than failing. The in-module test's cfgs are updated in lockstep so the empty-result assertion covers the gated-off macOS build too. I had earlier deferred this gate as "unverifiable on a Linux dev box". That was wrong: cargo resolves a foreign target's graph without building it, so the shed is provable from any host — cargo tree --target aarch64-apple-darwin -e normal --prefix none \ --no-default-features --features tokenjuice-treesitter goes 294 → 288 packages, and `-i objc2` / `-i objc2-foundation` / `-i objc2-contacts` / `-i block2` all report "did not match any packages" with the feature off. Note that error IS the proof of absence — the exact non-zero inversion `scripts/assert-shed.sh` exists to warn about; that script does not take a `--target`, hence the raw cross-target invocation here. The Linux kernel-floor ratchet is deliberately unchanged at 312/285/6: these crates were never in the Linux graph, so this gate is a no-op there and there is no number to lower. Verified: default and gates-off builds clean; `memory::people` 49 tests pass; `check-feature-forwarding.mjs` passes with `contacts` forwarded to the shell. Co-authored-by: Medulla --- AGENTS.md | 1 + Cargo.toml | 31 +++++++++++++++++---- app/src-tauri/Cargo.toml | 1 + src/openhuman/memory/people/address_book.rs | 20 +++++++++---- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0e93a7a51d..b6815718b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -327,6 +327,7 @@ whole cohort or expect a delta of 0. | `mcp` | ON | `openhuman::mcp::server` (the `openhuman mcp` stdio/HTTP server), `openhuman::mcp::registry` (dynamic Smithery installs — `mcp_clients` RPC namespace, SQLite, boot spawn, supervisor, OAuth), `openhuman::mcp::audit` (write-audit log), and the static config-declared server set in `openhuman::mcp::config_servers`. ~19 agent tools, ~20k LOC | **none** (see scope note) | | `tui` | ON | `openhuman::tui` — the tabbed ratatui/crossterm CLI UI (Logs, Chat, Config, Settings), auto-opened by bare `openhuman` on interactive non-container hosts and forced with `openhuman tui` (alias `chat`). Runs the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | | `channels` | ON | `openhuman::channels` (external-messaging providers — Telegram/Discord/Slack/Signal/WhatsApp/iMessage/IRC/… — plus the channel runtime, controllers, host, proactive messaging + inbound dispatch) and the `channels::webview_accounts` / `webview_apis` / `webview_notifications` / `channels::whatsapp_data` webview-bridge domains (incl. the 3 `whatsapp_data_*` agent tools). **Carve-outs `channels::{traits, cli}` stay ungated.** | **28** via `tinychannels/{email,lark}` — the crate itself stays (load-bearing), its two heavy providers do not | +| `contacts` | ON | `memory::people::address_book`'s macOS CNContactStore reader — the address-book seeding path for the people domain. Leaf gate over a **pre-existing** off-state: the module already shipped a non-macOS `imp` stub returning an empty contact list, so the gate only widens that stub's cfg. `read`/`read_with`/`AddressBookError`/`SystemContactsSource` and the whole `people` RPC surface stay compiled in every build; off ⇒ a refresh seeds nothing instead of failing. | **6** on macOS (`objc2`, `objc2-foundation`, `objc2-contacts`, `block2` + 2 transitive). **No-op on Linux/Windows** — never in those graphs, so the kernel-floor ratchet does not move. Verify cross-target: `cargo tree --target aarch64-apple-darwin -e normal -i objc2-contacts --no-default-features --features tokenjuice-treesitter` (294 → 288 packages). | **Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. diff --git a/Cargo.toml b/Cargo.toml index 09894e0203..110f3a695b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -347,11 +347,13 @@ tokio-tungstenite = { version = "0.24", default-features = false, features = ["c [target.'cfg(target_os = "macos")'.dependencies] whisper-rs = { version = "0.16", features = ["metal"], optional = true } -# Contacts framework bindings for address book seeding. -objc2 = "0.6" -objc2-foundation = { version = "0.3", features = ["NSArray", "NSError", "NSObject", "NSString", "NSPredicate"] } -objc2-contacts = { version = "0.3.2", features = ["CNContact", "CNContactFetchRequest", "CNContactStore", "CNLabeledValue", "CNPhoneNumber"] } -block2 = "0.6" +# Contacts framework bindings for address book seeding. Exclusive to +# `memory::people::address_book` (verified: no other file in src/ names any of +# the four), so the default-ON `contacts` feature sheds the whole cohort. +objc2 = { version = "0.6", optional = true } +objc2-foundation = { version = "0.3", features = ["NSArray", "NSError", "NSObject", "NSString", "NSPredicate"], optional = true } +objc2-contacts = { version = "0.3.2", features = ["CNContact", "CNContactFetchRequest", "CNContactStore", "CNLabeledValue", "CNPhoneNumber"], optional = true } +block2 = { version = "0.6", optional = true } [target.'cfg(target_os = "linux")'.dependencies] landlock = { version = "0.4", optional = true } @@ -398,7 +400,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "channels", "tui", "medulla", "scheduler-gate", "file-logging", "prediction-markets"] +default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "channels", "tui", "medulla", "scheduler-gate", "file-logging", "prediction-markets", "contacts"] # HTTP + Socket.IO server transport (#5048): the `/rpc` JSON-RPC endpoint and # its auth middleware/CORS layer (`core::jsonrpc`, `core::auth`), the `/v1` # OpenAI-compatible router (`inference::http`), the ad-hoc static-dir file @@ -508,6 +510,23 @@ web3 = [ "dep:coins-bip39", ] +# macOS Contacts seeding for the people domain: `memory::people::address_book` +# reads CNContactStore to seed handles. Default-ON. Slim / headless builds opt +# out via `--no-default-features --features ""`, which +# sheds the exclusive `objc2` / `objc2-foundation` / `objc2-contacts` / `block2` +# cohort on macOS. Leaf gate with a pre-existing off-state: `address_book.rs` +# already carries a non-macOS `imp` stub returning an empty contact list, so the +# gate simply widens that stub's cfg — `read()`, `read_with()`, +# `AddressBookError` and `SystemContactsSource` stay compiled in every build and +# the `people` RPC surface is unchanged. Off ⇒ address-book refresh seeds +# nothing instead of failing. +# +# NOTE: no-op on Linux/Windows, where these crates were never in the graph. +# Verify the shed cross-target from any host: +# cargo tree --target aarch64-apple-darwin -e normal -i objc2-contacts \ +# --no-default-features --features tokenjuice-treesitter +contacts = ["dep:objc2", "dep:objc2-foundation", "dep:objc2-contacts", "dep:block2"] + # Polymarket prediction-market tools (read + trading). Implies `web3` because it # signs orders with the same EVM stack the wallet uses — an honest dependency # rather than a hidden one. Separate from `web3` so a build can have a wallet diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 8180b0bcd9..10a097c579 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -197,6 +197,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal # forwarded — but the checker does literal set membership with no # transitive resolution, so this needs its own line. "prediction-markets", + "contacts", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/src/openhuman/memory/people/address_book.rs b/src/openhuman/memory/people/address_book.rs index 0361c6a790..a31c1bc1b6 100644 --- a/src/openhuman/memory/people/address_book.rs +++ b/src/openhuman/memory/people/address_book.rs @@ -81,8 +81,11 @@ pub fn read() -> Result, AddressBookError> { } // ── macOS implementation ────────────────────────────────────────────────────── +// +// Gated on `contacts` as well as the target: the four objc2 crates this needs +// are exclusive to this module, so a slim macOS build sheds the whole cohort. -#[cfg(target_os = "macos")] +#[cfg(all(target_os = "macos", feature = "contacts"))] mod imp { use super::{AddressBookContact, AddressBookError}; @@ -269,9 +272,14 @@ mod imp { } } -// ── non-macOS stub ──────────────────────────────────────────────────────────── +// ── stub: non-macOS, or macOS with `contacts` compiled out ─────────────────── +// +// Pre-dates the gate — it already existed for Linux/Windows. Widening its cfg +// is the whole off-state: `read()`, `read_with()`, `AddressBookError` and +// `SystemContactsSource` stay compiled everywhere, so the `people` RPC surface +// is identical and an address-book refresh seeds nothing rather than failing. -#[cfg(not(target_os = "macos"))] +#[cfg(not(all(target_os = "macos", feature = "contacts")))] mod imp { use super::{AddressBookContact, AddressBookError}; @@ -344,13 +352,15 @@ pub mod tests { #[test] fn system_source_non_mac_returns_empty() { - #[cfg(not(target_os = "macos"))] + // Mirrors the `imp` cfgs above: the stub is what compiles whenever the + // real CNContactStore path is absent, whether by target or by gate. + #[cfg(not(all(target_os = "macos", feature = "contacts")))] { let source = SystemContactsSource; let result = read_with(&source).unwrap(); assert!(result.is_empty()); } - #[cfg(target_os = "macos")] + #[cfg(all(target_os = "macos", feature = "contacts"))] { // TCC state is environment-dependent; just verify no panic. let source = SystemContactsSource; From 82b6d01cec81e3be645a99777c00fb722f67c34f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 4 Aug 2026 14:43:22 +0300 Subject: [PATCH 2/4] test(config): align unreadable marker snapshot coverage --- .../app_state_credentials_raw_coverage_e2e.rs | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/tests/raw_coverage/app_state_credentials_raw_coverage_e2e.rs b/tests/raw_coverage/app_state_credentials_raw_coverage_e2e.rs index a38fc23757..7cc832254f 100644 --- a/tests/raw_coverage/app_state_credentials_raw_coverage_e2e.rs +++ b/tests/raw_coverage/app_state_credentials_raw_coverage_e2e.rs @@ -827,7 +827,7 @@ async fn snapshot_activates_user_dir_after_pending_revalidation_without_initial_ } #[tokio::test] -async fn snapshot_preserves_pending_session_when_revalidated_user_activation_fails() { +async fn snapshot_errors_without_clearing_pending_session_when_active_user_marker_is_unreadable() { let _lock = env_lock(); let (api_url, server_task, shutdown_tx) = auth_me_server( r#"{"data":{"id":"fresh-activation-failure","name":"Activation Failure","email":"activation-failure@example.test"}}"#, @@ -863,28 +863,16 @@ async fn snapshot_preserves_pending_session_when_revalidated_user_activation_fai ) .expect("seed activation-failure pending app session"); - let snap = snapshot() + let error = snapshot() .await - .expect("snapshot with failed pending activation") - .value; + .expect_err("unreadable active_user.toml must stop snapshot configuration resolution"); assert!( - snap.auth.is_authenticated, - "activation write failure should keep the pending local session for retry" - ); - assert_eq!( - snap.session_token.as_deref(), - Some("round14.pending.activation-failure") - ); - assert_eq!( - snap.current_user - .as_ref() - .and_then(|v| v.get("pendingBackendValidation")), - Some(&json!(true)), - "failed activation must not return a validated backend user" + error.contains("read active user marker"), + "snapshot must surface the unreadable marker instead of resolving to the pre-login profile: {error}" ); assert!( active_user_root.join("active_user.toml").is_dir(), - "failed active_user.toml write must not be treated as an activated user" + "unreadable active_user.toml must remain in place" ); let profile = AuthService::from_config(&config) @@ -901,7 +889,7 @@ async fn snapshot_preserves_pending_session_when_revalidated_user_activation_fai assert_eq!( stored_user.get("pendingBackendValidation"), Some(&json!(true)), - "activation failure must not clear persisted pendingBackendValidation" + "configuration-resolution failure must not clear persisted pendingBackendValidation" ); let _ = shutdown_tx.send(()); From 508e6cff7b830a81fac8b9dd1d0516a36e38420d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 4 Aug 2026 14:50:29 +0300 Subject: [PATCH 3/4] ci(ci-lite): include address book tests in gated test allowlist The CI workflow now runs tests for the memory people address book module and includes it in the expected gated-test file set, ensuring the new module's tests are properly covered and the feature-gate smoke check remains accurate. --- .github/workflows/ci-lite.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 8c2ee0aeb4..1f523ec806 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -445,7 +445,7 @@ jobs: run: | bash scripts/ci-cancel-aware.sh cargo test --manifest-path Cargo.toml \ --no-default-features --features tokenjuice-treesitter --lib -- \ - core::all:: core::cli:: core::jsonrpc:: core::legacy_aliases:: core::runtime:: agent::registry::agents::loader:: tools::schemas:: tools::ops::tests:: + core::all:: core::cli:: core::jsonrpc:: core::legacy_aliases:: core::runtime:: agent::registry::agents::loader:: memory::people::address_book::tests:: tools::schemas:: tools::ops::tests:: bash scripts/ci-cancel-aware.sh cargo test --manifest-path Cargo.toml \ --no-default-features --features tokenjuice-treesitter,mcp --lib -- \ mcp::server::resources:: @@ -498,6 +498,7 @@ jobs: openhuman/agent/harness/subagent_runner/tool_prep.rs openhuman/agent/registry/agents/loader.rs openhuman/inference/local/mod.rs + openhuman/memory/people/address_book.rs openhuman/mcp/server/resources.rs openhuman/platform/socket/ops.rs openhuman/tinyplace/manifest.rs @@ -511,7 +512,7 @@ jobs: openhuman/web3/x402/stub.rs EOF ) - ACTUAL=$(grep -rlE '#\[cfg\((not\()?feature = "(voice|media|web3|meet|mcp|skills|flows|channels|prediction-markets)"' src --include='*.rs' \ + ACTUAL=$(grep -rlE '#\[cfg\([^]]*feature = "(voice|media|web3|meet|mcp|skills|flows|channels|prediction-markets|contacts)"' src --include='*.rs' \ | xargs grep -lE '#\[test\]|#\[tokio::test\]|fn .*_test' 2>/dev/null | sed 's|^src/||' | sort -u) if ! diff <(echo "$EXPECTED" | sed 's/^ *//' | sort -u) <(echo "$ACTUAL"); then echo "::error::Gated-test file set changed. Update the EXPECTED allowlist in the rust-feature-gate-smoke lane, and extend the scoped 'cargo test' filter if the new module can carry an ungated-assert regression (see #5022)." From a23e5f5839740ef6deed386f364907625762bf68 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 4 Aug 2026 14:51:01 +0300 Subject: [PATCH 4/4] ci(ci-lite): update gated-test detection regex The regex used to detect gated test files now also matches `cfg(not(feature = ...))` attributes and `cfg(all(..., feature = "contacts"))` patterns, ensuring the allowlist check covers additional feature-gating styles used in the codebase. --- .github/workflows/ci-lite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 1f523ec806..b914828565 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -512,7 +512,7 @@ jobs: openhuman/web3/x402/stub.rs EOF ) - ACTUAL=$(grep -rlE '#\[cfg\([^]]*feature = "(voice|media|web3|meet|mcp|skills|flows|channels|prediction-markets|contacts)"' src --include='*.rs' \ + ACTUAL=$(grep -rlE '#\[cfg\((not\()?feature = "(voice|media|web3|meet|mcp|skills|flows|channels|prediction-markets|contacts)"|#\[cfg\((not\()?all\([^]]*feature = "contacts"' src --include='*.rs' \ | xargs grep -lE '#\[test\]|#\[tokio::test\]|fn .*_test' 2>/dev/null | sed 's|^src/||' | sort -u) if ! diff <(echo "$EXPECTED" | sed 's/^ *//' | sort -u) <(echo "$ACTUAL"); then echo "::error::Gated-test file set changed. Update the EXPECTED allowlist in the rust-feature-gate-smoke lane, and extend the scoped 'cargo test' filter if the new module can carry an ungated-assert regression (see #5022)."