From 4efd80db341b9bcf2ef42587f66620205d9e566a Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:31:31 +0530 Subject: [PATCH 01/72] chore(core): remove zero-reference deps and redirect_links domain (#5052) --- Cargo.lock | 321 +--------------- Cargo.toml | 33 +- app/package.json | 3 - app/src-tauri/Cargo.lock | 324 +---------------- package.json | 1 - pnpm-lock.yaml | 63 ---- scripts/generate-test-inventory.mjs | 1 - src/core/all.rs | 9 - src/core/all_tests.rs | 1 - src/openhuman/mod.rs | 1 - src/openhuman/redirect_links/README.md | 82 ----- src/openhuman/redirect_links/mod.rs | 24 -- src/openhuman/redirect_links/ops.rs | 464 ------------------------ src/openhuman/redirect_links/schemas.rs | 315 ---------------- src/openhuman/redirect_links/store.rs | 337 ----------------- src/openhuman/redirect_links/types.rs | 25 -- src/openhuman/security/policy/types.rs | 4 + 17 files changed, 40 insertions(+), 1968 deletions(-) delete mode 100644 src/openhuman/redirect_links/README.md delete mode 100644 src/openhuman/redirect_links/mod.rs delete mode 100644 src/openhuman/redirect_links/ops.rs delete mode 100644 src/openhuman/redirect_links/schemas.rs delete mode 100644 src/openhuman/redirect_links/store.rs delete mode 100644 src/openhuman/redirect_links/types.rs diff --git a/Cargo.lock b/Cargo.lock index 989f24c3cf..dff2cdac7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,15 +958,6 @@ dependencies = [ "strsim", ] -[[package]] -name = "clap_complete" -version = "4.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" -dependencies = [ - "clap", -] - [[package]] name = "clap_derive" version = "4.6.1" @@ -1033,7 +1024,7 @@ dependencies = [ "bs58", "coins-core", "digest 0.10.7", - "hmac 0.12.1", + "hmac", "k256", "serde", "sha2 0.10.9", @@ -1048,7 +1039,7 @@ checksum = "3db8fba409ce3dc04f7d804074039eb68b960b0829161f8e06c95fea3f122528" dependencies = [ "bitvec", "coins-bip32", - "hmac 0.12.1", + "hmac", "once_cell", "pbkdf2 0.12.2", "rand 0.8.6", @@ -1747,19 +1738,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dialoguer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" -dependencies = [ - "console", - "fuzzy-matcher", - "shell-words", - "tempfile", - "zeroize", -] - [[package]] name = "digest" version = "0.10.7" @@ -1781,7 +1759,6 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", - "ctutils", ] [[package]] @@ -2123,7 +2100,7 @@ dependencies = [ "ctr", "digest 0.10.7", "hex", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "rand 0.8.6", "scrypt", @@ -2274,12 +2251,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2643,15 +2614,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -2691,7 +2653,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -2914,7 +2876,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", + "hmac", ] [[package]] @@ -2926,15 +2888,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - [[package]] name = "hostname" version = "0.4.2" @@ -3873,7 +3826,7 @@ dependencies = [ "indexmap", "itoa", "log", - "md-5 0.10.6", + "md-5", "nom 8.0.0", "nom_locate", "rand 0.9.4", @@ -3982,16 +3935,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - [[package]] name = "md5" version = "0.8.0" @@ -4068,7 +4011,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -4581,15 +4524,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -4708,7 +4642,6 @@ dependencies = [ "anyhow", "arboard", "argon2", - "async-imap", "async-trait", "axum", "base64 0.22.1", @@ -4720,14 +4653,12 @@ dependencies = [ "chrono", "chrono-tz", "clap", - "clap_complete", "coins-bip39", "console", "cpal", "cron", "crossterm", "curve25519-dalek", - "dialoguer", "directories", "dirs 5.0.1", "docx-rs", @@ -4747,7 +4678,7 @@ dependencies = [ "glob", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "hostname", "hound", "iana-time-zone", @@ -4757,30 +4688,22 @@ dependencies = [ "lettre", "libc", "log", - "mail-parser", "motosan-ai-oauth", "nu-ansi-term 0.46.0", "objc2 0.6.4", "objc2-contacts", "objc2-foundation 0.3.2", "once_cell", - "opentelemetry", - "opentelemetry-otlp", - "opentelemetry_sdk", "parking_lot", "pdf-extract", - "postgres", "ppt-rs", - "prometheus", "proptest", - "prost", "rand 0.10.1", "ratatui", "rdev", "regex", "reqwest 0.12.28", "ring", - "ripemd", "rppal", "rusqlite", "rustls", @@ -4793,7 +4716,6 @@ dependencies = [ "serde_yaml", "sha1", "sha2 0.10.9", - "shellexpand", "similar", "socketioxide", "starship-battery", @@ -4818,13 +4740,7 @@ dependencies = [ "tracing-appender", "tracing-log", "tracing-subscriber", - "tree-sitter", - "tree-sitter-python", - "tree-sitter-rust", - "tree-sitter-typescript", "uiautomation", - "unicode-normalization", - "unicode-segmentation", "unicode-width", "url", "urlencoding", @@ -4884,75 +4800,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "opentelemetry" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" -dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-http" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" -dependencies = [ - "async-trait", - "bytes", - "http 1.4.0", - "opentelemetry", - "reqwest 0.13.1", -] - -[[package]] -name = "opentelemetry-otlp" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" -dependencies = [ - "http 1.4.0", - "opentelemetry", - "opentelemetry-http", - "opentelemetry-proto", - "opentelemetry_sdk", - "prost", - "reqwest 0.13.1", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-proto" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" -dependencies = [ - "opentelemetry", - "opentelemetry_sdk", - "prost", -] - -[[package]] -name = "opentelemetry_sdk" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" -dependencies = [ - "futures-channel", - "futures-executor", - "futures-util", - "opentelemetry", - "percent-encoding", - "portable-atomic", - "rand 0.9.4", - "thiserror 2.0.18", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -5100,7 +4947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", "password-hash 0.4.2", "sha2 0.10.9", ] @@ -5112,7 +4959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", ] [[package]] @@ -5217,7 +5064,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_shared 0.13.1", - "serde", ] [[package]] @@ -5429,50 +5275,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postgres" -version = "0.19.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1" -dependencies = [ - "bytes", - "fallible-iterator 0.2.0", - "futures-util", - "log", - "tokio", - "tokio-postgres", -] - -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "hmac 0.13.0", - "md-5 0.11.0", - "memchr", - "rand 0.10.1", - "sha2 0.11.0", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "chrono", - "fallible-iterator 0.2.0", - "postgres-protocol", -] - [[package]] name = "postscript" version = "0.14.1" @@ -5562,20 +5364,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prometheus" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" -dependencies = [ - "cfg-if", - "fnv", - "lazy_static", - "memchr", - "parking_lot", - "thiserror 2.0.18", -] - [[package]] name = "proptest" version = "1.11.0" @@ -6170,7 +5958,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", ] [[package]] @@ -6179,7 +5966,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -6283,7 +6070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4" dependencies = [ "bitflags 2.13.1", - "fallible-iterator 0.3.0", + "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -6520,7 +6307,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" dependencies = [ - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "salsa20", "sha2 0.10.9", @@ -6897,21 +6684,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shellexpand" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" -dependencies = [ - "dirs 6.0.0", -] - [[package]] name = "shlex" version = "1.3.0" @@ -7575,7 +7347,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hmac 0.12.1", + "hmac", "lettre", "mail-parser", "parking_lot", @@ -7687,7 +7459,7 @@ dependencies = [ "ed25519-dalek", "futures-util", "hkdf", - "hmac 0.12.1", + "hmac", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -7772,32 +7544,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.13.1", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.10.1", - "socket2", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -8545,7 +8291,7 @@ dependencies = [ "futures", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "log", "md5", "once_cell", @@ -8632,7 +8378,7 @@ dependencies = [ "ghash 0.6.0", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "log", "prost", "rand 0.10.1", @@ -8711,15 +8457,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -8738,15 +8475,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -9079,19 +8807,6 @@ dependencies = [ "semver", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -10082,7 +9797,7 @@ dependencies = [ "crc32fast", "crossbeam-utils", "flate2", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "sha1", "time", diff --git a/Cargo.toml b/Cargo.toml index c8fbbac0d3..51d395f834 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,13 +96,6 @@ tinyagents = { version = "1.7", features = ["sqlite"] } # link. Keep the version pin in lockstep with the submodule tag. tinycortex = { version = "0.1", features = ["git-diff", "persona", "sync"] } tinychannels = { version = "0.1", features = ["relay-websocket"] } -# TokenJuice code compressor — AST-aware signature extraction. Optional (C build) -# behind the default `tokenjuice-treesitter` feature; disabling it falls back to -# the language-agnostic brace-depth heuristic. See src/openhuman/tokenjuice/compressors/code.rs. -tree-sitter = { version = "0.26", optional = true } -tree-sitter-rust = { version = "0.24", optional = true } -tree-sitter-typescript = { version = "0.23", optional = true } -tree-sitter-python = { version = "0.25", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_repr = "0.1" @@ -180,21 +173,16 @@ cron = "0.12" futures-util = "0.3" directories = "6" toml = "1.0" -shellexpand = "3.1" schemars = "1.2" tracing = { version = "0.1", default-features = false } tracing-log = "0.2" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter"] } tracing-appender = "0.2" -prometheus = { version = "0.14", default-features = false } urlencoding = "2.1" motosan-ai-oauth = { version = "0.2", features = ["codex"] } thiserror = "2.0" ring = "0.17" -prost = { version = "0.14", default-features = false } -postgres = { version = "0.19", features = ["with-chrono-0_4"] } chrono-tz = "0.10" -dialoguer = { version = "0.12", features = ["fuzzy-select"] } dotenvy = "0.15" console = "0.16" regex = "1.10" @@ -206,14 +194,6 @@ regex = "1.10" aho-corasick = "1.1" walkdir = "2" glob = "0.3" -unicode-segmentation = "1" -unicode-width = "0.2" -# NFKC + combining-mark detection for the cross-thread search inverted -# index (`memory_conversations::tokenize`). NFKC unifies CJK half/full- -# width variants and Arabic presentation forms; `canonical_combining_class` -# lets us strip diacritics across all scripts (Polish ą→a, Arabic harakat, -# Hebrew niqqud, etc.) without per-language tables. -unicode-normalization = "0.1" hostname = "0.4.2" rustls = { version = "0.23", features = ["ring"] } rustls-pki-types = "1.14.0" @@ -222,15 +202,9 @@ webpki-roots = "1.0.6" sysinfo = { version = "0.33", default-features = false, features = ["system"] } keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } clap = { version = "4.5", features = ["derive"] } -clap_complete = "4.5" lettre = { version = "0.11.22", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"], optional = true } -mail-parser = "0.11.2" -async-imap = { version = "0.11", features = ["runtime-tokio"], default-features = false } axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] } tower = { version = "0.5", default-features = false } -opentelemetry = { version = "0.32", default-features = false, features = ["trace", "metrics"] } -opentelemetry_sdk = { version = "0.32", default-features = false, features = ["trace", "metrics"] } -opentelemetry-otlp = { version = "0.32", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] } sentry = { version = "0.47.0", default-features = false, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "reqwest", "rustls"] } tokio-stream = { version = "0.1.18", features = ["full"] } url = "2" @@ -255,11 +229,9 @@ ethers-signers = { version = "2.0.14", default-features = false } # - bitcoin: P2WPKH PSBT build/sign/broadcast (includes secp256k1). # - ed25519-dalek: Solana transaction signing. # - bs58: Solana base58 addresses + Tron base58check addresses. -# - ripemd: RIPEMD160 for BTC HASH160 (P2WPKH) and Tron address hash. bitcoin = { version = "0.32", default-features = false, features = ["std", "secp-recovery", "rand-std"], optional = true } ed25519-dalek = { version = "2", default-features = false, features = ["std", "rand_core"] } bs58 = { version = "0.5", default-features = false, features = ["std", "check"] } -ripemd = "0.1" # Shared BIP-39 mnemonic → seed for non-EVM chains (BTC P2WPKH derivation, # Tron secp256k1 derivation, Solana ed25519 SLIP-0010 derivation). Same crate # ethers-signers uses internally, exposed as a direct dep so we can derive @@ -282,6 +254,9 @@ ppt-rs = "0.2.14" # `src/openhuman/tui/` behind `#[cfg(feature = "tui")]`. ratatui = { version = "0.30", optional = true } crossterm = { version = "0.29", optional = true } +# Terminal column-width measurement for the `tui` chat renderer +# (`src/openhuman/tui/render.rs`); only compiled behind `#[cfg(feature = "tui")]`. +unicode-width = { version = "0.2", optional = true } # Native-Rust `.docx` writer for the `generate_document` tool (GH #4847). # Pure Rust (no subprocess / managed runtime), MIT-licensed, actively # maintained (2.8M downloads, last release 0.4.20 — Apr 2026). Mirrors the @@ -478,7 +453,7 @@ mcp = [] # are `#[cfg(feature = "tui")]`; when off, `tui::stub::run_from_cli` returns a # build-fact "tui feature disabled at compile time" error from the untouched # `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern). -tui = ["dep:ratatui", "dep:crossterm"] +tui = ["dep:ratatui", "dep:crossterm", "dep:unicode-width"] sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] diff --git a/app/package.json b/app/package.json index 14ad445157..8875fb9868 100644 --- a/app/package.json +++ b/app/package.json @@ -78,8 +78,6 @@ "@noble/secp256k1": "^3.0.0", "@radix-ui/react-dialog": "^1.1.15", "@reduxjs/toolkit": "^2.11.2", - "@remotion/player": "4.0.454", - "@remotion/zod-types": "4.0.454", "@rive-app/react-webgl2": "^4.28.6", "@scure/base": "^2.2.0", "@scure/bip32": "^2.0.1", @@ -116,7 +114,6 @@ "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", - "remotion": "4.0.454", "socket.io-client": "^4.8.3", "tauri-plugin-ptt-api": "workspace:*", "three": "^0.183.2", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index f4eefc9f3c..cdacc45430 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -1217,15 +1217,6 @@ dependencies = [ "strsim", ] -[[package]] -name = "clap_complete" -version = "4.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" -dependencies = [ - "clap", -] - [[package]] name = "clap_derive" version = "4.6.1" @@ -1262,12 +1253,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" - [[package]] name = "cocoa" version = "0.22.0" @@ -1292,7 +1277,7 @@ dependencies = [ "bs58", "coins-core", "digest 0.10.7", - "hmac 0.12.1", + "hmac", "k256", "serde", "sha2 0.10.9", @@ -1307,7 +1292,7 @@ checksum = "3db8fba409ce3dc04f7d804074039eb68b960b0829161f8e06c95fea3f122528" dependencies = [ "bitvec", "coins-bip32", - "hmac 0.12.1", + "hmac", "once_cell", "pbkdf2 0.12.2", "rand 0.8.6", @@ -1805,15 +1790,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1999,19 +1975,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dialoguer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" -dependencies = [ - "console", - "fuzzy-matcher", - "shell-words", - "tempfile", - "zeroize", -] - [[package]] name = "digest" version = "0.10.7" @@ -2033,7 +1996,6 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", - "ctutils", ] [[package]] @@ -2536,7 +2498,7 @@ dependencies = [ "ctr", "digest 0.10.7", "hex", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "rand 0.8.6", "scrypt", @@ -2687,12 +2649,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -3047,15 +3003,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - [[package]] name = "fxhash" version = "0.2.1" @@ -3563,7 +3510,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", + "hmac", ] [[package]] @@ -3575,15 +3522,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - [[package]] name = "hostname" version = "0.4.2" @@ -4598,7 +4536,7 @@ dependencies = [ "indexmap 2.14.0", "itoa", "log", - "md-5 0.10.6", + "md-5", "nom 8.0.0", "nom_locate", "rand 0.9.4", @@ -4748,16 +4686,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - [[package]] name = "memchr" version = "2.8.0" @@ -5452,15 +5380,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "objc2-core-foundation", -] - [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -5610,7 +5529,6 @@ dependencies = [ "anyhow", "arboard", "argon2", - "async-imap", "async-trait", "axum", "base64 0.22.1", @@ -5622,13 +5540,11 @@ dependencies = [ "chrono", "chrono-tz", "clap", - "clap_complete", "coins-bip39", "console", "cpal", "cron", "curve25519-dalek", - "dialoguer", "directories 6.0.0", "dirs 5.0.1", "docx-rs", @@ -5646,7 +5562,7 @@ dependencies = [ "glob", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "hostname", "hound", "iana-time-zone", @@ -5655,28 +5571,20 @@ dependencies = [ "lettre", "libc", "log", - "mail-parser", "motosan-ai-oauth", "nu-ansi-term 0.46.0", "objc2 0.6.4", "objc2-contacts", "objc2-foundation 0.3.2", "once_cell", - "opentelemetry", - "opentelemetry-otlp", - "opentelemetry_sdk", "parking_lot", "pdf-extract", - "postgres", "ppt-rs", - "prometheus", - "prost", "rand 0.10.1", "rdev", "regex", "reqwest 0.12.28", "ring", - "ripemd", "rusqlite", "rustls", "rustls-pki-types", @@ -5688,7 +5596,6 @@ dependencies = [ "serde_yaml", "sha1", "sha2 0.10.9", - "shellexpand", "similar", "socketioxide", "starship-battery", @@ -5714,9 +5621,6 @@ dependencies = [ "tracing-log", "tracing-subscriber", "uiautomation", - "unicode-normalization", - "unicode-segmentation", - "unicode-width", "url", "urlencoding", "uuid 1.23.1", @@ -5774,75 +5678,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "opentelemetry" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" -dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-http" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" -dependencies = [ - "async-trait", - "bytes", - "http", - "opentelemetry", - "reqwest 0.13.1", -] - -[[package]] -name = "opentelemetry-otlp" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" -dependencies = [ - "http", - "opentelemetry", - "opentelemetry-http", - "opentelemetry-proto", - "opentelemetry_sdk", - "prost", - "reqwest 0.13.1", - "thiserror 2.0.18", -] - -[[package]] -name = "opentelemetry-proto" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" -dependencies = [ - "opentelemetry", - "opentelemetry_sdk", - "prost", -] - -[[package]] -name = "opentelemetry_sdk" -version = "0.32.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368afaed344110f40b179bb8fbe54bc52d98f9bd2b281799ef32487c2650c956" -dependencies = [ - "futures-channel", - "futures-executor", - "futures-util", - "opentelemetry", - "percent-encoding", - "portable-atomic", - "rand 0.9.4", - "thiserror 2.0.18", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -6022,7 +5857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", "password-hash 0.4.2", "sha2 0.10.9", ] @@ -6034,7 +5869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac 0.12.1", + "hmac", ] [[package]] @@ -6433,50 +6268,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postgres" -version = "0.19.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1" -dependencies = [ - "bytes", - "fallible-iterator 0.2.0", - "futures-util", - "log", - "tokio", - "tokio-postgres", -] - -[[package]] -name = "postgres-protocol" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "hmac 0.13.0", - "md-5 0.11.0", - "memchr", - "rand 0.10.1", - "sha2 0.11.0", - "stringprep", -] - -[[package]] -name = "postgres-types" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" -dependencies = [ - "bytes", - "chrono", - "fallible-iterator 0.2.0", - "postgres-protocol", -] - [[package]] name = "postscript" version = "0.14.1" @@ -6617,20 +6408,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prometheus" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" -dependencies = [ - "cfg-if", - "fnv", - "lazy_static", - "memchr", - "parking_lot", - "thiserror 2.0.18", -] - [[package]] name = "proptest" version = "1.11.0" @@ -7146,7 +6923,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.7", ] [[package]] @@ -7169,7 +6945,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -7303,7 +7079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4" dependencies = [ "bitflags 2.11.1", - "fallible-iterator 0.3.0", + "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -7576,7 +7352,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" dependencies = [ - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "salsa20", "sha2 0.10.9", @@ -8076,21 +7852,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shellexpand" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" -dependencies = [ - "dirs 6.0.0", -] - [[package]] name = "shlex" version = "1.3.0" @@ -9258,7 +9019,7 @@ dependencies = [ "futures", "futures-util", "hex", - "hmac 0.12.1", + "hmac", "lettre", "mail-parser", "parking_lot", @@ -9365,7 +9126,7 @@ dependencies = [ "ed25519-dalek", "futures-util", "hkdf", - "hmac 0.12.1", + "hmac", "rand 0.8.6", "reqwest 0.12.28", "serde", @@ -9442,32 +9203,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-postgres" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator 0.2.0", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.13.1", - "pin-project-lite", - "postgres-protocol", - "postgres-types", - "rand 0.10.1", - "socket2", - "tokio", - "tokio-util", - "whoami", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -10364,15 +10099,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -10391,15 +10117,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[package]] -name = "wasite" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" -dependencies = [ - "wasi 0.14.7+wasi-0.2.4", -] - [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -10728,19 +10445,6 @@ dependencies = [ "semver", ] -[[package]] -name = "whoami" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -dependencies = [ - "libc", - "libredox", - "objc2-system-configuration", - "wasite", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" @@ -11915,7 +11619,7 @@ dependencies = [ "crc32fast", "crossbeam-utils", "flate2", - "hmac 0.12.1", + "hmac", "pbkdf2 0.11.0", "sha1", "time", diff --git a/package.json b/package.json index e87ca4f8cc..f38a97b6a0 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "ws": "^8.20.0" }, "dependencies": { - "@rive-app/react-canvas": "^4.28.6", "@tauri-apps/api": "2.10.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6af4d50258..39b69ad822 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: .: dependencies: - '@rive-app/react-canvas': - specifier: ^4.28.6 - version: 4.28.6(react@19.2.5) '@tauri-apps/api': specifier: 2.10.1 version: 2.10.1 @@ -48,12 +45,6 @@ importers: '@reduxjs/toolkit': specifier: ^2.11.2 version: 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5) - '@remotion/player': - specifier: 4.0.454 - version: 4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@remotion/zod-types': - specifier: 4.0.454 - version: 4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6) '@rive-app/react-webgl2': specifier: ^4.28.6 version: 4.28.6(react@19.2.5) @@ -162,9 +153,6 @@ importers: remark-math: specifier: ^6.0.0 version: 6.0.0 - remotion: - specifier: 4.0.454 - version: 4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5) socket.io-client: specifier: ^4.8.3 version: 4.8.3 @@ -1407,25 +1395,6 @@ packages: react-redux: optional: true - '@remotion/player@4.0.454': - resolution: {integrity: sha512-Q4fEWapH21uEErdOfZxagOQcxbf/JGxPwqBOrW/NG9Y5qHgMMe5tBUCnr8rEqOA4txW/S4QHnN9HGWfV2nIvTg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@remotion/zod-types@4.0.454': - resolution: {integrity: sha512-nNXcHF4AmgihwI7sMx50rSgFuCfNyroP0OsgN/mMkQI7Y66uPWBh4Pcytlxs039EShvsiiH621E2TKZOX8ciRA==} - peerDependencies: - zod: 4.3.6 - - '@rive-app/canvas@2.37.8': - resolution: {integrity: sha512-nffrPG+VkBKHAxZdcqYlP5M+n/mOAoagj774HH397UPTsfC27gwQURg64i6dX7WswMc5qIVX0rzCrZ86Wb2HOA==} - - '@rive-app/react-canvas@4.28.6': - resolution: {integrity: sha512-tMEb7uDr+xuPny4HRVnkfGDHgTCkTFn15u7GHj12hC94N59g/dO15TiGr9312GVTO82FulOymOKy6sgxZyi5rw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0 - '@rive-app/react-webgl2@4.28.6': resolution: {integrity: sha512-QqtlD7bm01SxMLuEHE/BfhiPoX3RZ2/B32nF0IR0IRPe2ykxMeebWPtsX50AL9EKLbOh3Fgk3PJ7smkkvQwD2w==} peerDependencies: @@ -5196,12 +5165,6 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} - remotion@4.0.454: - resolution: {integrity: sha512-NGzA7HLBpzRPo1jAyLGXG7AaAdUYHJ18n06GM3nBvFkC5zVZua+2rhezxDTkMJFEMgL54cRQ2Zw/2yd3cWCRbg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -7097,27 +7060,6 @@ snapshots: react: 19.2.5 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1) - '@remotion/player@4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - remotion: 4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - - '@remotion/zod-types@4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6)': - dependencies: - remotion: 4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - zod: 4.3.6 - transitivePeerDependencies: - - react - - react-dom - - '@rive-app/canvas@2.37.8': {} - - '@rive-app/react-canvas@4.28.6(react@19.2.5)': - dependencies: - '@rive-app/canvas': 2.37.8 - react: 19.2.5 - '@rive-app/react-webgl2@4.28.6(react@19.2.5)': dependencies: '@rive-app/webgl2': 2.37.8 @@ -11677,11 +11619,6 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - remotion@4.0.454(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - require-directory@2.1.1: {} require-from-string@2.0.2: {} diff --git a/scripts/generate-test-inventory.mjs b/scripts/generate-test-inventory.mjs index 83880dfc16..61f0f86655 100644 --- a/scripts/generate-test-inventory.mjs +++ b/scripts/generate-test-inventory.mjs @@ -68,7 +68,6 @@ const DOMAIN_ALLOWLIST = new Set([ 'plan_review', 'provider_surfaces', 'recall_calendar', - 'redirect_links', 'referral', 'session_import', 'skill_runtime', diff --git a/src/core/all.rs b/src/core/all.rs index b5821c13da..6fd7fd648e 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -575,12 +575,6 @@ fn build_registered_controllers() -> Vec { DomainGroup::Memory, crate::openhuman::memory_diff::all_memory_diff_registered_controllers(), ); - // Link shortener for long tracking URLs — saves LLM tokens - push( - &mut controllers, - DomainGroup::Platform, - crate::openhuman::redirect_links::all_redirect_links_registered_controllers(), - ); // Referral and growth tracking push( &mut controllers, @@ -951,9 +945,6 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> { "memory_diff" => Some( "Snapshot-based change tracking for memory sources — capture state, compute diffs, and surface changes to agents.", ), - "redirect_links" => Some( - "Shorten long tracking URLs to `openhuman://link/` placeholders (SQLite-backed) to save tokens in prompts, with round-trip rewrite helpers.", - ), "referral" => Some("Referral codes, stats, and apply flows via the hosted backend API."), "run_ledger" => Some( "Durable agent and workflow run state, child lineage, events, telemetry, and checkpoint references.", diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index a562c711e5..5667eabeb6 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -119,7 +119,6 @@ fn registered_controller_rpc_method_name() { fn namespace_description_known_namespaces() { assert!(namespace_description("memory").is_some()); assert!(namespace_description("memory_tree").is_some()); - assert!(namespace_description("redirect_links").is_some()); assert!(namespace_description("billing").is_some()); assert!(namespace_description("config").is_some()); assert!(namespace_description("health").is_some()); diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index dc26574017..3bff808817 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -100,7 +100,6 @@ pub mod profiles; pub mod prompt_injection; pub mod provider_surfaces; pub mod recall_calendar; -pub mod redirect_links; pub mod referral; #[cfg(feature = "flows")] pub mod rhai_workflows; diff --git a/src/openhuman/redirect_links/README.md b/src/openhuman/redirect_links/README.md deleted file mode 100644 index 1f5d8984d2..0000000000 --- a/src/openhuman/redirect_links/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# redirect_links - -Redirect-link shortener for token-heavy URLs. Long tracking URLs (e.g. `trip.com/forward/...?bizData=...`) burn model tokens every time they pass through a prompt. This domain encodes them to a short `openhuman://link/` placeholder on inbound text, keeps the full URL in a local SQLite store, and expands the placeholder back to the original URL on outbound messages so the user never sees the placeholder. It also has a separate helper for tagging public `openhm.xyz` short links with a `?u=` attribution param on the way out. - -## Responsibilities - -- **Shorten** a long URL to a content-addressed `openhuman://link/` form and persist it (idempotent / deterministic by URL). -- **Expand** a short id back to its full URL, bumping a hit counter + `last_used_at`. -- **Inbound rewrite**: replace every long URL (≥ `min_len`, default 80) in a text blob with its placeholder, preserving surrounding prose and trailing sentence punctuation. -- **Outbound rewrite**: replace every `openhuman://link/` placeholder in text back to the stored URL; unknown ids are left untouched (nothing silently disappears). -- **Public-link attribution**: append `?u=` (URL-encoded, idempotent, fragment-safe) to `openhm.xyz/` URLs, guarding against lookalike domains. -- List and remove stored links; expose all of the above over JSON-RPC. - -## Key files - -| File | Role | -| --- | --- | -| `src/openhuman/redirect_links/mod.rs` | Export-focused: module docstring, `mod` decls, `pub use` re-exports of ops + schemas + types; aliases `ops as rpc`. | -| `src/openhuman/redirect_links/types.rs` | Serde types: `RedirectLink`, `RewriteReplacement`, `RewriteResult`. | -| `src/openhuman/redirect_links/ops.rs` | Business logic: URL/short-URL/public-URL regexes, inbound/outbound rewrite, `append_user_id_to_public_links`, and the `rl_*` RPC handlers returning `RpcOutcome`. Holds `DEFAULT_MIN_URL_LEN = 80`. | -| `src/openhuman/redirect_links/store.rs` | SQLite persistence: `shorten`/`expand`/`peek`/`list`/`remove`, content-addressed id allocation (SHA-256 hex prefix), schema bootstrap, id<->short-URL helpers (`short_url_for`, `id_from_short`, `SHORT_URL_PREFIX`). | -| `src/openhuman/redirect_links/schemas.rs` | Controller schemas (`all_controller_schemas`, `all_registered_controllers`, `schemas`) + `handle_*` fns delegating to `ops.rs`. | - -## Public surface - -Re-exported from `mod.rs`: - -- Functions (via `pub use ops::…`): `shorten_url`, `expand_link`, `rewrite_inbound`, `rewrite_outbound`, `rewrite_outbound_for_user`, `append_user_id_to_public_links`. -- `pub use ops as rpc` — exposes the `rl_*` handlers under `redirect_links::rpc`. -- Schemas: `all_redirect_links_controller_schemas`, `all_redirect_links_registered_controllers`, `redirect_links_schemas`. -- Types: `RedirectLink`, `RewriteReplacement`, `RewriteResult`. - -Also defined (not re-exported through `mod.rs`): `ops::rewrite_inbound_with_threshold`, `ops::DEFAULT_MIN_URL_LEN`; `store::{shorten, expand, peek, list, remove, short_url_for, id_from_short, SHORT_URL_PREFIX}`. - -## RPC / controllers - -Namespace `redirect_links` (RPC methods `openhuman.redirect_links_`): - -| Function | Inputs | Output | -| --- | --- | --- | -| `shorten` | `url: String` | `link: RedirectLink` | -| `expand` | `id: String` | `link: RedirectLink` (errors if not found) | -| `list` | `limit?: u64` (default 50, max 1000) | `links: RedirectLink[]` (newest first) | -| `remove` | `id: String` | `{ id, removed: bool }` | -| `rewrite_inbound` | `text: String`, `min_len?: u64` (default 80) | `result: RewriteResult` | -| `rewrite_outbound` | `text: String` | `result: RewriteResult` | - -Handlers load config via `config::rpc::load_config_with_timeout()` and return `RpcOutcome` serialized with `into_cli_compatible_json()`. - -## Persistence - -SQLite DB at `{config.workspace_dir}/redirect_links/links.db`, table `redirect_links`: - -| Column | Notes | -| --- | --- | -| `id` | TEXT PRIMARY KEY — SHA-256(url) hex prefix, 8 chars default, grown by 2 up to 32 on prefix collision with a different URL. | -| `url` | TEXT NOT NULL UNIQUE (indexed). | -| `created_at` | RFC3339 TEXT. | -| `last_used_at` | RFC3339 TEXT, nullable; set on each expand. | -| `hit_count` | INTEGER, bumped on each expand. | - -Insert is atomic (`ON CONFLICT DO NOTHING`) so concurrent shortens of the same URL converge on one id with no PRIMARY KEY / UNIQUE error (regression-tested). The connection is opened per call and the schema is created if missing. - -## Dependencies - -- `crate::openhuman::config::Config` — supplies `workspace_dir` for the DB path; handlers call `config::rpc::load_config_with_timeout`. -- `crate::core::all` (`ControllerFuture`, `RegisteredController`) and `crate::core::{ControllerSchema, FieldSchema, TypeSchema}` — controller registry wiring. -- `crate::rpc::RpcOutcome` — RPC return envelope. -- External crates: `rusqlite` (SQLite), `sha2` + `hex` (content-addressed ids), `regex` (URL matching), `chrono` (timestamps), `urlencoding` (user-id encoding), `serde`/`serde_json`, `anyhow`. - -## Used by - -- `src/core/all.rs` registers the controllers + schemas and maps the `"redirect_links"` namespace description; `src/openhuman/mod.rs` declares the module. No other in-tree Rust callers of the rewrite/shorten functions were found — the rewrite pipeline is currently reachable via RPC rather than wired into an inbound/outbound message path inside the core. - -## Notes / gotchas - -- **Ids are content-addressed, not random**: same URL → same id (deterministic, deduped). Removing a link and re-shortening the same URL yields the same id again. -- **Length threshold guards token waste**: the placeholder is ~24 bytes, so URLs below `DEFAULT_MIN_URL_LEN` (80) are left untouched by inbound rewrite. -- **Trailing punctuation handling**: inbound rewrite and public-link tagging strip trailing `. , ; : !` so prose with a URL followed by a period doesn't capture the period into the stored/tagged URL. -- **`append_user_id_to_public_links` is anchored to `openhm.xyz`** specifically and rejects lookalikes (`evil-openhm.xyz`, `openhm.xyz.evil.com`); it splits off `#fragment` so `?u=` always lands in the query, and is idempotent against existing `?u=`/`&u=`. -- **`id_from_short`** accepts both `openhuman://link/` and a bare hex ``, lowercasing the result; non-hex input returns `None`. -- **No agent tools, no event-bus subscribers, no `bus.rs`/`tools.rs`** — this domain is store + ops + RPC only. diff --git a/src/openhuman/redirect_links/mod.rs b/src/openhuman/redirect_links/mod.rs deleted file mode 100644 index 308424567a..0000000000 --- a/src/openhuman/redirect_links/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Redirect-link shortener for token-heavy URLs. -//! -//! Long tracking URLs (e.g. `trip.com/forward/...?bizData=...`) burn tokens -//! whenever they pass through a model. This domain encodes them to a short -//! `openhuman://link/` form for inbound prompts, keeps the full URL in -//! a local SQLite store, and expands them back on outbound messages so the -//! user never sees the placeholder. - -pub mod ops; -mod schemas; -mod store; -mod types; - -pub use ops as rpc; -pub use ops::{ - append_user_id_to_public_links, expand_link, rewrite_inbound, rewrite_outbound, - rewrite_outbound_for_user, shorten_url, -}; -pub use schemas::{ - all_controller_schemas as all_redirect_links_controller_schemas, - all_registered_controllers as all_redirect_links_registered_controllers, - schemas as redirect_links_schemas, -}; -pub use types::{RedirectLink, RewriteReplacement, RewriteResult}; diff --git a/src/openhuman/redirect_links/ops.rs b/src/openhuman/redirect_links/ops.rs deleted file mode 100644 index 2e4eb5e4c5..0000000000 --- a/src/openhuman/redirect_links/ops.rs +++ /dev/null @@ -1,464 +0,0 @@ -use anyhow::Result; -use regex::Regex; -use serde_json::{json, Value}; -use std::sync::OnceLock; - -use crate::openhuman::config::Config; -use crate::openhuman::redirect_links::store; -use crate::openhuman::redirect_links::types::{RedirectLink, RewriteReplacement, RewriteResult}; -use crate::rpc::RpcOutcome; - -/// URLs shorter than this are not worth rewriting — the `openhuman://link/` -/// placeholder is ~24 bytes, so shortening below this just wastes work and -/// tokens. Callers may override via `rewrite_inbound_with_threshold`. -pub const DEFAULT_MIN_URL_LEN: usize = 80; - -fn url_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - // Wider than the reference regex to catch common tracking-URL characters - // (`#`, `:`, `+`, `@`, `~`, `!`, `,`, `;`). Trailing sentence punctuation - // is stripped below so regular prose doesn't get mangled. - RE.get_or_init(|| Regex::new(r#"https?://[\w\d./\?=%\-&#:+@~!,;]+"#).unwrap()) -} - -fn short_url_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"openhuman://link/([0-9a-f]+)").unwrap()) -} - -fn public_url_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - // Anchor on `https?://` and match the `openhm.xyz` domain specifically to - // avoid lookalikes (evil-openhm.xyz) or mid-token matches. Capture optional - // query and fragment as separate tail parts so callers can safely insert - // `?u=` into the query without polluting the fragment. - RE.get_or_init(|| { - Regex::new( - r#"https?://openhm\.xyz/[A-Za-z0-9_-]+(?:\?[\w\d./\?=%\-&:+@~!,;]*)?(?:#[\w\d./\?=%\-&:+@~!,;]*)?"#, - ) - .unwrap() - }) -} - -/// Strip trailing sentence punctuation (`.`, `,`, `;`, `:`, `!`) so that -/// "see https://example.com/path." doesn't capture the period. -fn trim_trailing_punct(s: &str) -> &str { - s.trim_end_matches(['.', ',', ';', ':', '!']) -} - -/// Shorten a single URL, persisting it in the global store. Idempotent. -pub fn shorten_url(config: &Config, url: &str) -> Result { - store::shorten(config, url) -} - -/// Expand a previously-shortened id back to its full URL. Bumps hit count. -pub fn expand_link(config: &Config, id: &str) -> Result> { - store::expand(config, id) -} - -/// Rewrite every long URL in `text` to `openhuman://link/`, using the -/// default length threshold. -pub fn rewrite_inbound(config: &Config, text: &str) -> Result { - rewrite_inbound_with_threshold(config, text, DEFAULT_MIN_URL_LEN) -} - -pub fn rewrite_inbound_with_threshold( - config: &Config, - text: &str, - min_len: usize, -) -> Result { - let re = url_regex(); - let mut replacements: Vec = Vec::new(); - let mut out = String::with_capacity(text.len()); - let mut cursor = 0usize; - - for m in re.find_iter(text) { - out.push_str(&text[cursor..m.start()]); - let raw = m.as_str(); - let url = trim_trailing_punct(raw); - let trailing = &raw[url.len()..]; - - if url.len() >= min_len { - let link = store::shorten(config, url)?; - out.push_str(&link.short_url); - replacements.push(RewriteReplacement { - original: url.to_string(), - replacement: link.short_url, - id: link.id, - }); - } else { - out.push_str(url); - } - out.push_str(trailing); - cursor = m.end(); - } - out.push_str(&text[cursor..]); - - Ok(RewriteResult { - text: out, - replacements, - }) -} - -/// Replace every `openhuman://link/` placeholder with its stored URL. -/// Unknown ids are left as-is so nothing silently disappears. -pub fn rewrite_outbound(config: &Config, text: &str) -> Result { - let re = short_url_regex(); - let mut replacements: Vec = Vec::new(); - let mut out = String::with_capacity(text.len()); - let mut cursor = 0usize; - - for caps in re.captures_iter(text) { - let whole = caps.get(0).unwrap(); - let id = caps.get(1).unwrap().as_str(); - out.push_str(&text[cursor..whole.start()]); - - match store::expand(config, id)? { - Some(link) => { - out.push_str(&link.url); - replacements.push(RewriteReplacement { - original: whole.as_str().to_string(), - replacement: link.url, - id: link.id, - }); - } - None => { - out.push_str(whole.as_str()); - } - } - cursor = whole.end(); - } - out.push_str(&text[cursor..]); - - Ok(RewriteResult { - text: out, - replacements, - }) -} - -/// Convenience wrapper that runs `rewrite_outbound` and then appends the -/// `user_id` to any public `openhm.xyz` links in the result. -pub fn rewrite_outbound_for_user( - config: &Config, - text: &str, - user_id: Option<&str>, -) -> Result { - let mut result = rewrite_outbound(config, text)?; - result.text = append_user_id_to_public_links(&result.text, user_id); - Ok(result) -} - -/// Append `?u=` to every `openhm.xyz/` URL in a string. -/// If `user_id` is `None`, the text is returned unchanged. -/// Idempotent: URLs already containing a `u=` query parameter are left alone. -pub fn append_user_id_to_public_links(text: &str, user_id: Option<&str>) -> String { - let Some(user_id) = user_id else { - return text.to_string(); - }; - - let re = public_url_regex(); - let encoded_user_id = urlencoding::encode(user_id); - let mut out = String::with_capacity(text.len()); - let mut cursor = 0usize; - - for m in re.find_iter(text) { - out.push_str(&text[cursor..m.start()]); - let raw = m.as_str(); - let url = trim_trailing_punct(raw); - let trailing = &raw[url.len()..]; - - // Split off any fragment (#…) so `?u=` lands in the query, not the fragment. - let (base, fragment) = match url.split_once('#') { - Some((b, f)) => (b, Some(f)), - None => (url, None), - }; - - if !base.contains("?u=") && !base.contains("&u=") { - let separator = if base.contains('?') { "&" } else { "?" }; - out.push_str(base); - out.push_str(separator); - out.push_str("u="); - out.push_str(&encoded_user_id); - } else { - out.push_str(base); - } - if let Some(frag) = fragment { - out.push('#'); - out.push_str(frag); - } - out.push_str(trailing); - cursor = m.end(); - } - out.push_str(&text[cursor..]); - out -} - -// ── RPC handlers ──────────────────────────────────────────────────────── - -pub async fn rl_shorten(config: &Config, url: &str) -> Result, String> { - let link = store::shorten(config, url).map_err(|e| e.to_string())?; - Ok(RpcOutcome::single_log( - link.clone(), - format!( - "[redirect_links][rpc][shorten] id={} short_url={} original_url_len={}", - link.id, - link.short_url, - link.url.len() - ), - )) -} - -pub async fn rl_expand(config: &Config, id: &str) -> Result, String> { - match store::expand(config, id).map_err(|e| e.to_string())? { - Some(link) => Ok(RpcOutcome::new( - serde_json::to_value(&link).map_err(|e| e.to_string())?, - vec![format!( - "[redirect_links][rpc][expand] id={} hit_count={}", - link.id, link.hit_count - )], - )), - None => Err(format!("[redirect_links][rpc][expand] not found: id={id}")), - } -} - -pub async fn rl_list(config: &Config, limit: Option) -> Result, String> { - let limit = limit.unwrap_or(50).clamp(1, 1_000); - let links = store::list(config, limit).map_err(|e| e.to_string())?; - Ok(RpcOutcome::new( - json!({ "links": links }), - vec![format!("[redirect_links][rpc][list] count={}", links.len())], - )) -} - -pub async fn rl_remove(config: &Config, id: &str) -> Result, String> { - let removed = store::remove(config, id).map_err(|e| e.to_string())?; - Ok(RpcOutcome::new( - json!({ "id": id, "removed": removed }), - vec![format!( - "[redirect_links][rpc][remove] id={id} removed={removed}" - )], - )) -} - -pub async fn rl_rewrite_inbound( - config: &Config, - text: &str, - min_len: Option, -) -> Result, String> { - let result = - rewrite_inbound_with_threshold(config, text, min_len.unwrap_or(DEFAULT_MIN_URL_LEN)) - .map_err(|e| e.to_string())?; - let count = result.replacements.len(); - Ok(RpcOutcome::single_log( - result, - format!("[redirect_links][rpc][rewrite_inbound] replaced={count}"), - )) -} - -pub async fn rl_rewrite_outbound( - config: &Config, - text: &str, -) -> Result, String> { - let result = rewrite_outbound(config, text).map_err(|e| e.to_string())?; - let count = result.replacements.len(); - Ok(RpcOutcome::single_log( - result, - format!("[redirect_links][rpc][rewrite_outbound] expanded={count}"), - )) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::config::Config; - use tempfile::TempDir; - - fn test_config(tmp: &TempDir) -> Config { - let mut cfg = Config::default(); - cfg.workspace_dir = tmp.path().join("workspace"); - std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); - cfg - } - - const LONG: &str = - "https://www.trip.com/forward/middlepages/channel/openEdm.gif?bizData=eyJldmVudCI6Im9wZW4iLCJmaWxlSWQiOiJmaWxlX2EwOD"; - - #[test] - fn inbound_shortens_long_urls_and_preserves_surrounding_text() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = format!("click here: {LONG} thanks"); - let result = rewrite_inbound(&cfg, &text).unwrap(); - assert!(result.text.starts_with("click here: openhuman://link/")); - assert!(result.text.ends_with(" thanks")); - assert_eq!(result.replacements.len(), 1); - } - - #[test] - fn inbound_leaves_short_urls_untouched() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = "see https://a.co/x for more"; - let result = rewrite_inbound(&cfg, text).unwrap(); - assert_eq!(result.text, text); - assert!(result.replacements.is_empty()); - } - - #[test] - fn inbound_trims_trailing_sentence_punctuation() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = format!("open {LONG}."); - let result = rewrite_inbound(&cfg, &text).unwrap(); - assert!(result.text.ends_with(".")); - // The stored URL must not carry the trailing period. - let link = &result.replacements[0]; - assert!(!link.original.ends_with('.')); - } - - #[test] - fn outbound_expands_placeholders_roundtrip() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = format!("go: {LONG}"); - let inbound = rewrite_inbound(&cfg, &text).unwrap(); - let outbound = rewrite_outbound(&cfg, &inbound.text).unwrap(); - assert_eq!(outbound.text, text); - } - - #[test] - fn outbound_leaves_unknown_ids_unchanged() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = "no match: openhuman://link/ffffffff"; - let result = rewrite_outbound(&cfg, text).unwrap(); - assert_eq!(result.text, text); - assert!(result.replacements.is_empty()); - } - - #[test] - fn inbound_handles_multiple_urls_in_one_string() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let text = format!("{LONG} and also {LONG}?extra=1234567890abcdef"); - let result = rewrite_inbound(&cfg, &text).unwrap(); - assert_eq!(result.replacements.len(), 2); - } - - #[test] - fn append_user_id_to_public_links_bare() { - let text = "https://openhm.xyz/abc"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, "https://openhm.xyz/abc?u=nikhil"); - } - - #[test] - fn append_user_id_to_public_links_query() { - let text = "https://openhm.xyz/abc?foo=bar"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, "https://openhm.xyz/abc?foo=bar&u=nikhil"); - } - - #[test] - fn append_user_id_to_public_links_idempotent() { - // Already ?u= - let text = "https://openhm.xyz/abc?u=existing"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, text); - - // Already &u= - let text = "https://openhm.xyz/abc?foo=bar&u=existing"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, text); - } - - #[test] - fn append_user_id_to_public_links_none() { - let text = "https://openhm.xyz/abc"; - let got = append_user_id_to_public_links(text, None); - assert_eq!(got, text); - } - - #[test] - fn append_user_id_to_public_links_no_match() { - let text = "https://example.com/abc"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, text); - } - - #[test] - fn append_user_id_to_public_links_multiple() { - let text = "https://openhm.xyz/a and https://openhm.xyz/b?x=y"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!( - got, - "https://openhm.xyz/a?u=nikhil and https://openhm.xyz/b?x=y&u=nikhil" - ); - } - - #[test] - fn append_user_id_to_public_links_encoding() { - let text = "https://openhm.xyz/abc"; - let got = append_user_id_to_public_links(text, Some("nikhil@example.com + space")); - assert_eq!( - got, - "https://openhm.xyz/abc?u=nikhil%40example.com%20%2B%20space" - ); - } - - #[test] - fn append_user_id_to_public_links_lookalikes() { - let text = "https://evil-openhm.xyz/abc and openhm.xyz.evil.com/abc"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, text); - } - - #[test] - fn append_user_id_to_public_links_punctuation() { - let text = "Click https://openhm.xyz/abc."; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, "Click https://openhm.xyz/abc?u=nikhil."); - } - - #[test] - fn append_user_id_to_public_links_query_with_fragment() { - let text = "https://openhm.xyz/abc?foo=bar#frag"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, "https://openhm.xyz/abc?foo=bar&u=nikhil#frag"); - } - - #[test] - fn append_user_id_to_public_links_bare_with_fragment() { - let text = "https://openhm.xyz/abc#frag"; - let got = append_user_id_to_public_links(text, Some("nikhil")); - assert_eq!(got, "https://openhm.xyz/abc?u=nikhil#frag"); - } - - #[test] - fn rewrite_outbound_for_user_expands_placeholder_and_tags_public_url() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - - // Shorten LONG into an openhuman:// placeholder, then craft outbound text - // that mixes the placeholder with a public openhm.xyz URL. - let inbound = rewrite_inbound(&cfg, LONG).unwrap(); - let placeholder = &inbound.replacements[0].replacement; - let text = format!("see {placeholder} and https://openhm.xyz/abc"); - - let result = rewrite_outbound_for_user(&cfg, &text, Some("nikhil")).unwrap(); - assert!( - result.text.contains(LONG), - "placeholder must expand back to LONG" - ); - assert!( - result.text.contains("https://openhm.xyz/abc?u=nikhil"), - "openhm.xyz URL must carry ?u= tag" - ); - - // None user_id leaves openhm.xyz untouched but still expands placeholder. - let result_none = rewrite_outbound_for_user(&cfg, &text, None).unwrap(); - assert!(result_none.text.contains(LONG)); - assert!(result_none.text.contains("https://openhm.xyz/abc")); - assert!(!result_none.text.contains("?u=")); - } -} diff --git a/src/openhuman/redirect_links/schemas.rs b/src/openhuman/redirect_links/schemas.rs deleted file mode 100644 index 8e94adfe2d..0000000000 --- a/src/openhuman/redirect_links/schemas.rs +++ /dev/null @@ -1,315 +0,0 @@ -use serde::de::DeserializeOwned; -use serde_json::{Map, Value}; - -use crate::core::all::{ControllerFuture, RegisteredController}; -use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; -use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::redirect_links::ops as rl_ops; -use crate::rpc::RpcOutcome; - -pub fn all_controller_schemas() -> Vec { - vec![ - schemas("shorten"), - schemas("expand"), - schemas("list"), - schemas("remove"), - schemas("rewrite_inbound"), - schemas("rewrite_outbound"), - ] -} - -pub fn all_registered_controllers() -> Vec { - vec![ - RegisteredController { - schema: schemas("shorten"), - handler: handle_shorten, - }, - RegisteredController { - schema: schemas("expand"), - handler: handle_expand, - }, - RegisteredController { - schema: schemas("list"), - handler: handle_list, - }, - RegisteredController { - schema: schemas("remove"), - handler: handle_remove, - }, - RegisteredController { - schema: schemas("rewrite_inbound"), - handler: handle_rewrite_inbound, - }, - RegisteredController { - schema: schemas("rewrite_outbound"), - handler: handle_rewrite_outbound, - }, - ] -} - -pub fn schemas(function: &str) -> ControllerSchema { - match function { - "shorten" => ControllerSchema { - namespace: "redirect_links", - function: "shorten", - description: "Persist a long URL and return its `openhuman://link/` short form.", - inputs: vec![FieldSchema { - name: "url", - ty: TypeSchema::String, - comment: "The full URL to shorten.", - required: true, - }], - outputs: vec![FieldSchema { - name: "link", - ty: TypeSchema::Ref("RedirectLink"), - comment: "The stored redirect link record.", - required: true, - }], - }, - "expand" => ControllerSchema { - namespace: "redirect_links", - function: "expand", - description: "Resolve a short id back to its full URL and bump hit count.", - inputs: vec![FieldSchema { - name: "id", - ty: TypeSchema::String, - comment: "The short id (the hex portion after `openhuman://link/`).", - required: true, - }], - outputs: vec![FieldSchema { - name: "link", - ty: TypeSchema::Ref("RedirectLink"), - comment: "The resolved redirect link record.", - required: true, - }], - }, - "list" => ControllerSchema { - namespace: "redirect_links", - function: "list", - description: "List stored redirect links, newest first.", - inputs: vec![FieldSchema { - name: "limit", - ty: TypeSchema::Option(Box::new(TypeSchema::U64)), - comment: "Maximum number of links to return (default 50, max 1000).", - required: false, - }], - outputs: vec![FieldSchema { - name: "links", - ty: TypeSchema::Array(Box::new(TypeSchema::Ref("RedirectLink"))), - comment: "Stored redirect links.", - required: true, - }], - }, - "remove" => ControllerSchema { - namespace: "redirect_links", - function: "remove", - description: "Delete a redirect link by id.", - inputs: vec![FieldSchema { - name: "id", - ty: TypeSchema::String, - comment: "Redirect link id to remove.", - required: true, - }], - outputs: vec![FieldSchema { - name: "result", - ty: TypeSchema::Object { - fields: vec![ - FieldSchema { - name: "id", - ty: TypeSchema::String, - comment: "Id requested for removal.", - required: true, - }, - FieldSchema { - name: "removed", - ty: TypeSchema::Bool, - comment: "True when a row was deleted.", - required: true, - }, - ], - }, - comment: "Removal result.", - required: true, - }], - }, - "rewrite_inbound" => ControllerSchema { - namespace: "redirect_links", - function: "rewrite_inbound", - description: - "Rewrite every long URL in `text` to an `openhuman://link/` placeholder \ - to save tokens before a prompt hits the model. URLs shorter than `min_len` \ - are left untouched.", - inputs: vec![ - FieldSchema { - name: "text", - ty: TypeSchema::String, - comment: "Text to rewrite.", - required: true, - }, - FieldSchema { - name: "min_len", - ty: TypeSchema::Option(Box::new(TypeSchema::U64)), - comment: "Minimum URL length to shorten; defaults to 80.", - required: false, - }, - ], - outputs: vec![FieldSchema { - name: "result", - ty: TypeSchema::Ref("RewriteResult"), - comment: "Rewritten text and per-URL replacement records.", - required: true, - }], - }, - "rewrite_outbound" => ControllerSchema { - namespace: "redirect_links", - function: "rewrite_outbound", - description: - "Expand every `openhuman://link/` placeholder in `text` back to its full \ - URL before the message reaches the user.", - inputs: vec![FieldSchema { - name: "text", - ty: TypeSchema::String, - comment: "Text to rewrite.", - required: true, - }], - outputs: vec![FieldSchema { - name: "result", - ty: TypeSchema::Ref("RewriteResult"), - comment: "Rewritten text and per-placeholder expansion records.", - required: true, - }], - }, - _other => ControllerSchema { - namespace: "redirect_links", - function: "unknown", - description: "Unknown redirect_links controller function.", - inputs: vec![FieldSchema { - name: "function", - ty: TypeSchema::String, - comment: "Unknown function requested for schema lookup.", - required: true, - }], - outputs: vec![FieldSchema { - name: "error", - ty: TypeSchema::String, - comment: "Lookup error details.", - required: true, - }], - }, - } -} - -fn handle_shorten(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let url = read_required::(¶ms, "url")?; - to_json(rl_ops::rl_shorten(&config, url.trim()).await?) - }) -} - -fn handle_expand(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let id = read_required::(¶ms, "id")?; - to_json(rl_ops::rl_expand(&config, id.trim()).await?) - }) -} - -fn handle_list(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let limit = read_optional_u64(¶ms, "limit")? - .map(|raw| usize::try_from(raw).map_err(|_| "limit is too large for usize".to_string())) - .transpose()?; - to_json(rl_ops::rl_list(&config, limit).await?) - }) -} - -fn handle_remove(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let id = read_required::(¶ms, "id")?; - to_json(rl_ops::rl_remove(&config, id.trim()).await?) - }) -} - -fn handle_rewrite_inbound(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let text = read_required::(¶ms, "text")?; - let min_len = read_optional_u64(¶ms, "min_len")? - .map(|raw| usize::try_from(raw).map_err(|_| "min_len too large for usize".to_string())) - .transpose()?; - to_json(rl_ops::rl_rewrite_inbound(&config, &text, min_len).await?) - }) -} - -fn handle_rewrite_outbound(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let text = read_required::(¶ms, "text")?; - to_json(rl_ops::rl_rewrite_outbound(&config, &text).await?) - }) -} - -fn read_required(params: &Map, key: &str) -> Result { - let value = params - .get(key) - .cloned() - .ok_or_else(|| format!("missing required param '{key}'"))?; - serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}")) -} - -fn read_optional_u64(params: &Map, key: &str) -> Result, String> { - match params.get(key) { - None => Ok(None), - Some(Value::Null) => Ok(None), - Some(Value::Number(n)) => n - .as_u64() - .map(Some) - .ok_or_else(|| format!("invalid '{key}': expected unsigned integer")), - Some(_) => Err(format!("invalid '{key}': expected unsigned integer")), - } -} - -fn to_json(outcome: RpcOutcome) -> Result { - outcome.into_cli_compatible_json() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn all_schemas_and_controllers_cover_every_function() { - let names: Vec<_> = all_controller_schemas() - .into_iter() - .map(|s| s.function) - .collect(); - assert_eq!( - names, - vec![ - "shorten", - "expand", - "list", - "remove", - "rewrite_inbound", - "rewrite_outbound", - ], - ); - assert_eq!(all_registered_controllers().len(), 6); - } - - #[test] - fn schemas_unknown_returns_placeholder() { - let s = schemas("does-not-exist"); - assert_eq!(s.function, "unknown"); - } - - #[test] - fn shorten_schema_requires_url() { - let s = schemas("shorten"); - assert_eq!(s.inputs.len(), 1); - assert!(s.inputs[0].required); - } -} diff --git a/src/openhuman/redirect_links/store.rs b/src/openhuman/redirect_links/store.rs deleted file mode 100644 index a88c78d55e..0000000000 --- a/src/openhuman/redirect_links/store.rs +++ /dev/null @@ -1,337 +0,0 @@ -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use rusqlite::{params, Connection, OptionalExtension}; -use sha2::{Digest, Sha256}; - -use crate::openhuman::config::Config; -use crate::openhuman::redirect_links::types::RedirectLink; - -pub const SHORT_URL_PREFIX: &str = "openhuman://link/"; -const DEFAULT_ID_LEN: usize = 8; -const MAX_ID_LEN: usize = 32; - -/// Build the short URL representation for an id. -pub fn short_url_for(id: &str) -> String { - format!("{SHORT_URL_PREFIX}{id}") -} - -/// Parse a short URL back into its id component. Accepts both -/// `openhuman://link/` and bare `` (hex only). -pub fn id_from_short(short: &str) -> Option { - let trimmed = short.trim(); - let candidate = trimmed.strip_prefix(SHORT_URL_PREFIX).unwrap_or(trimmed); - if !candidate.is_empty() && candidate.chars().all(|c| c.is_ascii_hexdigit()) { - Some(candidate.to_ascii_lowercase()) - } else { - None - } -} - -fn content_id(url: &str, len: usize) -> String { - let digest = Sha256::digest(url.as_bytes()); - hex::encode(digest)[..len.min(64)].to_string() -} - -pub fn shorten(config: &Config, url: &str) -> Result { - let url = url.trim(); - if url.is_empty() { - anyhow::bail!("url must not be empty"); - } - - with_connection(config, |conn| { - let mut len = DEFAULT_ID_LEN; - let now = Utc::now(); - loop { - if len > MAX_ID_LEN { - anyhow::bail!("failed to allocate unique redirect id after expansion"); - } - let id = content_id(url, len); - - // Atomic insert. If either `id` or `url` already exists, the - // statement becomes a no-op — no PRIMARY KEY / UNIQUE error under - // concurrent calls, so we don't need a pre-read. - let affected = conn - .execute( - "INSERT INTO redirect_links - (id, url, created_at, last_used_at, hit_count) - VALUES (?1, ?2, ?3, NULL, 0) - ON CONFLICT DO NOTHING", - params![id, url, now.to_rfc3339()], - ) - .context("failed to insert redirect_link")?; - - if affected > 0 { - return Ok(RedirectLink { - id: id.clone(), - url: url.to_string(), - short_url: short_url_for(&id), - created_at: now, - last_used_at: None, - hit_count: 0, - }); - } - - // Insert was a no-op. Either the URL is already stored (possibly - // under a longer id from a concurrent writer — idempotent return) - // or this id prefix collides with a different URL. - if let Some(existing) = find_by_url(conn, url)? { - return Ok(existing); - } - match get_by_id(conn, &id)? { - Some(existing) if existing.url == url => return Ok(existing), - Some(_) => { - // Hash-prefix collision with a different URL — lengthen. - len += 2; - continue; - } - None => { - // Race with a concurrent delete; retry this same length. - continue; - } - } - } - }) -} - -pub fn expand(config: &Config, id: &str) -> Result> { - let id = id.trim(); - if id.is_empty() { - return Ok(None); - } - with_connection(config, |conn| { - let found = get_by_id(conn, id)?; - if found.is_some() { - let now = Utc::now().to_rfc3339(); - conn.execute( - "UPDATE redirect_links - SET hit_count = hit_count + 1, last_used_at = ?2 - WHERE id = ?1", - params![id, now], - ) - .context("failed to bump redirect_link hit count")?; - } - Ok(found) - }) -} - -pub fn peek(config: &Config, id: &str) -> Result> { - let id = id.trim(); - if id.is_empty() { - return Ok(None); - } - with_connection(config, |conn| get_by_id(conn, id)) -} - -pub fn list(config: &Config, limit: usize) -> Result> { - with_connection(config, |conn| { - let mut stmt = conn.prepare( - "SELECT id, url, created_at, last_used_at, hit_count - FROM redirect_links - ORDER BY datetime(created_at) DESC - LIMIT ?1", - )?; - let rows = stmt - .query_map(params![limit as i64], row_to_link)? - .collect::, _>>()?; - Ok(rows) - }) -} - -pub fn remove(config: &Config, id: &str) -> Result { - with_connection(config, |conn| { - let affected = conn - .execute("DELETE FROM redirect_links WHERE id = ?1", params![id]) - .context("failed to delete redirect_link")?; - Ok(affected > 0) - }) -} - -fn get_by_id(conn: &Connection, id: &str) -> Result> { - conn.query_row( - "SELECT id, url, created_at, last_used_at, hit_count - FROM redirect_links WHERE id = ?1", - params![id], - row_to_link, - ) - .optional() - .map_err(Into::into) -} - -fn find_by_url(conn: &Connection, url: &str) -> Result> { - conn.query_row( - "SELECT id, url, created_at, last_used_at, hit_count - FROM redirect_links WHERE url = ?1", - params![url], - row_to_link, - ) - .optional() - .map_err(Into::into) -} - -fn row_to_link(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let id: String = row.get(0)?; - let url: String = row.get(1)?; - let created_at: String = row.get(2)?; - let last_used_at: Option = row.get(3)?; - let hit_count: i64 = row.get(4)?; - let created_at = parse_ts(&created_at)?; - let last_used_at = last_used_at.as_deref().map(parse_ts).transpose()?; - Ok(RedirectLink { - short_url: short_url_for(&id), - id, - url, - created_at, - last_used_at, - hit_count: hit_count.max(0) as u64, - }) -} - -fn parse_ts(s: &str) -> rusqlite::Result> { - DateTime::parse_from_rfc3339(s) - .map(|t| t.with_timezone(&Utc)) - .map_err(|e| { - rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e)) - }) -} - -fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) -> Result { - let db_path = config.workspace_dir.join("redirect_links").join("links.db"); - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create redirect_links directory: {}", - parent.display() - ) - })?; - } - - let conn = Connection::open(&db_path) - .with_context(|| format!("Failed to open redirect_links DB: {}", db_path.display()))?; - - conn.execute_batch( - "PRAGMA foreign_keys = ON; - CREATE TABLE IF NOT EXISTS redirect_links ( - id TEXT PRIMARY KEY, - url TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL, - last_used_at TEXT, - hit_count INTEGER NOT NULL DEFAULT 0 - ); - CREATE INDEX IF NOT EXISTS idx_redirect_links_url ON redirect_links(url);", - ) - .context("Failed to initialize redirect_links schema")?; - - f(&conn) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn test_config(tmp: &TempDir) -> Config { - let mut cfg = Config::default(); - cfg.workspace_dir = tmp.path().join("workspace"); - std::fs::create_dir_all(&cfg.workspace_dir).unwrap(); - cfg - } - - #[test] - fn shorten_is_deterministic_and_dedupes() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let url = "https://www.trip.com/forward/middlepages/channel/openEdm.gif?bizData=eyJldmVudCI6Im9wZW4ifQ"; - let a = shorten(&cfg, url).unwrap(); - let b = shorten(&cfg, url).unwrap(); - assert_eq!(a.id, b.id); - assert_eq!(a.short_url, format!("openhuman://link/{}", a.id)); - assert_eq!(a.id.len(), DEFAULT_ID_LEN); - } - - #[test] - fn expand_returns_original_url_and_bumps_hits() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let link = shorten(&cfg, "https://example.com/a?x=1").unwrap(); - let got = expand(&cfg, &link.id).unwrap().expect("link exists"); - assert_eq!(got.url, "https://example.com/a?x=1"); - assert_eq!(got.hit_count, 0); - let got2 = expand(&cfg, &link.id).unwrap().unwrap(); - assert_eq!(got2.hit_count, 1); - } - - #[test] - fn expand_unknown_id_returns_none() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - assert!(expand(&cfg, "deadbeef").unwrap().is_none()); - } - - #[test] - fn id_from_short_accepts_scheme_and_rejects_others() { - assert_eq!( - id_from_short("openhuman://link/abc123"), - Some("abc123".into()) - ); - assert!(id_from_short("https://example.com/").is_none()); - assert!(id_from_short("openhuman://link/").is_none()); - assert!(id_from_short("openhuman://link/not-hex!").is_none()); - } - - #[test] - fn id_from_short_accepts_bare_id_and_normalizes_case() { - // The docstring promises bare-id acceptance — lock it in. - assert_eq!(id_from_short("abc123").as_deref(), Some("abc123")); - assert_eq!(id_from_short(" ABC123 ").as_deref(), Some("abc123")); - assert!(id_from_short("").is_none()); - assert!(id_from_short("not-hex").is_none()); - } - - #[test] - fn shorten_handles_concurrent_calls_without_primary_key_error() { - // Regression test: the previous check-then-insert path raced under - // concurrent calls and hit a PRIMARY KEY constraint error. The - // ON CONFLICT DO NOTHING path must return the same link for every - // concurrent caller with the same URL. - use std::sync::Arc; - use std::thread; - - let tmp = TempDir::new().unwrap(); - let cfg = Arc::new(test_config(&tmp)); - let url = "https://example.com/concurrent?x=1".to_string(); - - let mut handles = Vec::new(); - for _ in 0..8 { - let cfg = Arc::clone(&cfg); - let url = url.clone(); - handles.push(thread::spawn(move || shorten(&cfg, &url).unwrap())); - } - let ids: Vec = handles.into_iter().map(|h| h.join().unwrap().id).collect(); - // Every concurrent writer must agree on a single id for the URL. - assert!(ids.iter().all(|id| id == &ids[0])); - } - - #[test] - fn list_orders_newest_first_and_respects_limit() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - for i in 0..5 { - shorten( - &cfg, - &format!("https://example.com/{i}?v=xxxxxxxxxxxxxxxxxxxx"), - ) - .unwrap(); - } - let rows = list(&cfg, 3).unwrap(); - assert_eq!(rows.len(), 3); - } - - #[test] - fn remove_deletes_and_reports_affected() { - let tmp = TempDir::new().unwrap(); - let cfg = test_config(&tmp); - let link = shorten(&cfg, "https://example.com/rm").unwrap(); - assert!(remove(&cfg, &link.id).unwrap()); - assert!(!remove(&cfg, &link.id).unwrap()); - } -} diff --git a/src/openhuman/redirect_links/types.rs b/src/openhuman/redirect_links/types.rs deleted file mode 100644 index 6d631a7671..0000000000 --- a/src/openhuman/redirect_links/types.rs +++ /dev/null @@ -1,25 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RedirectLink { - pub id: String, - pub url: String, - pub short_url: String, - pub created_at: DateTime, - pub last_used_at: Option>, - pub hit_count: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RewriteReplacement { - pub original: String, - pub replacement: String, - pub id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RewriteResult { - pub text: String, - pub replacements: Vec, -} diff --git a/src/openhuman/security/policy/types.rs b/src/openhuman/security/policy/types.rs index e6f02ab7ad..317eb3bb74 100644 --- a/src/openhuman/security/policy/types.rs +++ b/src/openhuman/security/policy/types.rs @@ -184,6 +184,10 @@ pub(super) const WORKSPACE_INTERNAL_DIRS: &[&str] = &[ "vault", "task_sources", "whatsapp_data", + // The redirect_links domain was removed (#5051), but an upgraded profile can + // still hold a legacy `redirect_links/links.db` (stored URL history) written + // by an older version. Keep the directory on the internal denylist so agents + // with workspace access cannot read or overwrite that leftover state. "redirect_links", "codegraph", ".openhuman", From bea380d954cc73a2c9910e210216bea3d3283af5 Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:31:51 +0530 Subject: [PATCH 02/72] feat(bench): embedded-RSS benchmark harness + report-only CI (#5046) (#5060) --- .github/workflows/ci-lite.yml | 48 ++ Cargo.toml | 12 + scripts/__tests__/feature-forwarding.test.mjs | 4 + scripts/ci/check-feature-forwarding.mjs | 15 +- scripts/lib/feature-forwarding.mjs | 17 + src/bin/rss_bench.rs | 436 +++++++++++++++++ src/openhuman/mod.rs | 1 + src/openhuman/proc_metrics/mod.rs | 446 ++++++++++++++++++ 8 files changed, 965 insertions(+), 14 deletions(-) create mode 100644 src/bin/rss_bench.rs create mode 100644 src/openhuman/proc_metrics/mod.rs diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 4ae6ae2029..4cfd70c64d 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -470,6 +470,54 @@ jobs: fi echo "gate-contract test coverage allowlist is current" + # Report-only (#5046). Measures steady-state RSS of an embedded openhuman_core + # agent roster and uploads the raw samples + a human summary. Deliberately NOT + # in `pr-ci-gate.needs`, so it can never fail a PR — it exists to accrue a + # baseline and its runner variance before the 30 MiB gate is flipped to + # blocking in a follow-up (add this job to pr-ci-gate + a threshold step then). + rust-rss-bench: + name: Rust RSS Benchmark (report-only) + needs: [changes] + if: needs.changes.outputs['rust-core'] == 'true' + runs-on: ubuntu-22.04 + timeout-minutes: 40 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:rust-1.93.0 + env: + CARGO_INCREMENTAL: "0" + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + persist-credentials: false + submodules: recursive + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + cache-on-failure: true + shared-key: pr-rust-rss-bench + + # Fixture-contract signal: the gated bin's unit tests (build_roster, + # warm-up turn) never enter the default coverage lane, so run them here. + - name: Run rss-bench fixture tests + run: bash scripts/ci-cancel-aware.sh cargo test --features rss-bench --bin rss-bench + + - name: Build stripped-release rss-bench + run: bash scripts/ci-cancel-aware.sh cargo build --release --features rss-bench --bin rss-bench + + - name: Measure embedded RSS (5 fresh procs x {1,8} agents) + run: ./target/release/rss-bench --out bench-rss.json | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload raw RSS samples + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: rss-bench-report + path: bench-rss.json + rust-core-coverage: name: Rust Core Coverage (cargo-llvm-cov) needs: [changes, rust-quality] diff --git a/Cargo.toml b/Cargo.toml index 51d395f834..4dcbbf1914 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,14 @@ path = "src/bin/test_mcp_stub.rs" name = "openhuman-fleet" path = "src/bin/fleet.rs" +# Embedded-RSS benchmark harness (#5046). Gated behind the default-OFF +# `rss-bench` feature so no benchmark code enters the shipped build. Build with +# `cargo build --release --features rss-bench --bin rss-bench`. +[[bin]] +name = "rss-bench" +path = "src/bin/rss_bench.rs" +required-features = ["rss-bench"] + [lib] name = "openhuman_core" crate-type = ["rlib"] @@ -466,6 +474,10 @@ whatsapp-web = ["tinychannels/whatsapp-web"] # build (app/scripts/e2e-build.sh) flips it on. Shipped binaries never have # this feature so the wipe RPC isn't even registered, let alone reachable. e2e-test-support = [] +# Builds the `rss-bench` benchmark binary (#5046). Default-OFF, so it is never +# part of the shipped desktop/library build and the feature-forwarding gate +# (which only inspects the `default` list) never requires forwarding it. +rss-bench = [] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } diff --git a/scripts/__tests__/feature-forwarding.test.mjs b/scripts/__tests__/feature-forwarding.test.mjs index 745c3a6c82..3bdd62379d 100644 --- a/scripts/__tests__/feature-forwarding.test.mjs +++ b/scripts/__tests__/feature-forwarding.test.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { diffForwarding, + INTENTIONALLY_NOT_FORWARDED, parseCoreDefaultFeatures, parseShellForwardedFeatures, stripComments, @@ -179,6 +180,9 @@ test('the real shell manifest forwards every real core default', () => { assert.ok(coreDefaults.length > 0, 'expected to parse at least one core default gate'); assert.equal(shell.defaultFeatures, false, 'shell is expected to set default-features = false'); for (const gate of coreDefaults) { + // Gates the shell intentionally does not forward (e.g. `tui` — a terminal + // subcommand the desktop app never runs) are exempt, matching the checker. + if (INTENTIONALLY_NOT_FORWARDED[gate]) continue; assert.ok( shell.features.includes(gate), `core default gate not forwarded to the shell: ${gate}` diff --git a/scripts/ci/check-feature-forwarding.mjs b/scripts/ci/check-feature-forwarding.mjs index a346f4c7dd..4a8e410dad 100644 --- a/scripts/ci/check-feature-forwarding.mjs +++ b/scripts/ci/check-feature-forwarding.mjs @@ -15,26 +15,13 @@ import { fileURLToPath } from 'node:url'; import { diffForwarding, formatReport, + INTENTIONALLY_NOT_FORWARDED, parseCoreDefaultFeatures, parseShellForwardedFeatures, } from '../lib/feature-forwarding.mjs'; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -/** - * Gates the desktop shell intentionally does NOT forward, mapped to why. - * - * Empty by design: every current default-ON gate belongs in the shipped app. - * Adding an entry is a deliberate product decision, not a way to silence this - * check — the reason string is what a future reader (and reviewer) relies on to - * tell "excluded on purpose" from "forgotten". That ambiguity is exactly what - * let #4918 sit unnoticed since #4123. - */ -const INTENTIONALLY_NOT_FORWARDED = { - // 'some-gate': 'Reason it must not ship in the desktop build.', - tui: 'Terminal UI subcommand (openhuman tui/chat); the desktop app ships its own Tauri UI and never runs the ratatui terminal front-end.', -}; - function usage() { return 'Usage: check-feature-forwarding.mjs [core-manifest] [shell-manifest]'; } diff --git a/scripts/lib/feature-forwarding.mjs b/scripts/lib/feature-forwarding.mjs index a105e0646b..91195ef39f 100644 --- a/scripts/lib/feature-forwarding.mjs +++ b/scripts/lib/feature-forwarding.mjs @@ -20,6 +20,23 @@ // only needs two well-known shapes, and the repo has no TOML dependency for // Node. It is regex/scanner-based in the same spirit as `checklist-parser.mjs`. +/** + * Gates the desktop shell intentionally does NOT forward, mapped to why. + * + * Adding an entry is a deliberate product decision, not a way to silence the + * forwarding guard — the reason string is what a future reader (and reviewer) + * relies on to tell "excluded on purpose" from "forgotten". That ambiguity is + * exactly what let #4918 sit unnoticed since #4123. + * + * Lives here (not in the checker) so both the CI checker and the self-test read + * the same source of truth — otherwise the self-test can demand a forward the + * checker legitimately exempts, which is exactly the drift #5084's `tui` gate hit. + */ +export const INTENTIONALLY_NOT_FORWARDED = { + // 'some-gate': 'Reason it must not ship in the desktop build.', + tui: 'Terminal UI subcommand (openhuman tui/chat); the desktop app ships its own Tauri UI and never runs the ratatui terminal front-end.', +}; + /** * Strip TOML `#` comments while respecting quoted strings, so a `#` inside a * value (or an issue number in a comment) can't truncate a real line. diff --git a/src/bin/rss_bench.rs b/src/bin/rss_bench.rs new file mode 100644 index 0000000000..493cc63df0 --- /dev/null +++ b/src/bin/rss_bench.rs @@ -0,0 +1,436 @@ +//! `rss-bench` — steady-state RSS benchmark for an embedded `openhuman_core` +//! agent roster (#5046). +//! +//! Mirrors the OpenCompany embedding contract: a bare [`Agent`] built directly +//! via [`Agent::builder`] (no `CoreBuilder`, no RPC, no background services) +//! with an injected mock provider, an in-process `"none"` memory backend, and a +//! per-agent temp workspace. Builds a 1-agent and an 8-agent roster, runs one +//! deterministic warm-up turn per agent to fault in lazy allocations, settles, +//! then samples `/proc/self/{status,smaps_rollup}`. +//! +//! Two modes: +//! * `--child --roster N` builds one roster in a **fresh process**, warms up, +//! settles, and prints one [`ProcSample`] JSON line. This is the isolated +//! measured workload. +//! * default (parent) re-execs itself `--repeat` times per roster size to get +//! independent cold samples, aggregates, writes the raw JSON report +//! (`--out`), and prints a human summary. +//! +//! Gated behind the default-OFF `rss-bench` feature so no benchmark code enters +//! the shipped desktop/library build. Build & run: +//! `cargo build --release --features rss-bench --bin rss-bench`. +//! +//! The pure sampling/aggregation logic lives in +//! [`openhuman_core::openhuman::proc_metrics`]; this binary is the fixture + +//! process driver. + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use openhuman_core::openhuman::agent::dispatcher::NativeToolDispatcher; +use openhuman_core::openhuman::agent::Agent; +use openhuman_core::openhuman::inference::provider::{ + ChatRequest, ChatResponse, Provider, UsageInfo, +}; +use openhuman_core::openhuman::memory::{ + Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts, +}; +use openhuman_core::openhuman::proc_metrics::{ + self, BenchReport, ProcSample, RosterResult, REPORT_SCHEMA_VERSION, RSS_BUDGET_KIB, + RSS_HARD_CAP_KIB, +}; +use openhuman_core::openhuman::tools::{Tool, ToolResult}; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use tempfile::TempDir; + +/// Roster sizes measured by default: the 1-agent baseline and the +/// representative 8-agent company roster from #5046. +const DEFAULT_ROSTER_SIZES: &[usize] = &[1, 8]; +/// Fresh processes sampled per roster size (≥ 5 per the issue). +const DEFAULT_REPEAT: usize = 5; +/// Per-child wall-clock budget. A child does bounded work (build a roster, one +/// warm-up turn, a ≤2 s settle), so anything beyond this is a stall — kill it and +/// fail the run rather than letting one bad child block the whole benchmark until +/// the outer CI job timeout. +const CHILD_TIMEOUT: Duration = Duration::from_secs(120); + +/// Provider that never touches the network: returns a fixed assistant message +/// with a `stop` shape (no tool calls) so a turn completes in one round-trip. +struct MockProvider; + +#[async_trait] +impl Provider for MockProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> Result { + Ok("ok".into()) + } + + async fn chat( + &self, + _request: ChatRequest<'_>, + _model: &str, + _temperature: f64, + ) -> Result { + Ok(ChatResponse { + text: Some("ok".into()), + tool_calls: vec![], + usage: Some(UsageInfo { + input_tokens: 8, + output_tokens: 2, + context_window: 8000, + charged_amount_usd: 0.0, + ..Default::default() + }), + reasoning_content: None, + }) + } +} + +/// Trivial host-supplied tool so the roster mirrors a real embedding (the host +/// injects its own tools). Never invoked — the provider returns no tool calls. +struct EchoTool; + +#[async_trait] +impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + + fn description(&self) -> &str { + "echo" + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ "type": "object" }) + } + + async fn execute(&self, _args: serde_json::Value) -> Result { + Ok(ToolResult::success("echo")) + } +} + +/// Zero-allocation no-op `Memory` for the fixture. +/// +/// The benchmark measures a bare agent under the OpenCompany embedding contract +/// (host supplies its own `Memory` over its context store), *not* a memory +/// store. `create_memory(MemoryConfig{ backend: "none", .. })` does **not** +/// select a no-op backend — it always builds a `UnifiedMemory` (SQLite + the +/// default cloud embedder), which would inflate the measured RSS with +/// memory-store setup. Injecting a real no-op keeps the reading on the agent +/// harness itself. +struct NoopMemory; + +#[async_trait] +impl Memory for NoopMemory { + fn name(&self) -> &str { + "noop" + } + async fn store( + &self, + _namespace: &str, + _key: &str, + _content: &str, + _category: MemoryCategory, + _session_id: Option<&str>, + ) -> Result<()> { + Ok(()) + } + async fn recall( + &self, + _query: &str, + _limit: usize, + _opts: RecallOpts<'_>, + ) -> Result> { + Ok(Vec::new()) + } + async fn get(&self, _namespace: &str, _key: &str) -> Result> { + Ok(None) + } + async fn list( + &self, + _namespace: Option<&str>, + _category: Option<&MemoryCategory>, + _session_id: Option<&str>, + ) -> Result> { + Ok(Vec::new()) + } + async fn forget(&self, _namespace: &str, _key: &str) -> Result { + Ok(false) + } + async fn namespace_summaries(&self) -> Result> { + Ok(Vec::new()) + } + async fn count(&self) -> Result { + Ok(0) + } + async fn health_check(&self) -> bool { + true + } +} + +/// A built roster plus the temp workspaces that must outlive it — dropping the +/// `TempDir`s would delete the agents' workspaces mid-measurement. +struct Roster { + agents: Vec, + _workspaces: Vec, +} + +/// Build `n` bare agents, each with its own temp workspace, mock provider, +/// `"none"` memory backend, and a single host-supplied tool. +fn build_roster(n: usize) -> Result { + let mut agents = Vec::with_capacity(n); + let mut workspaces = Vec::with_capacity(n); + for i in 0..n { + let workspace = TempDir::new().context("create temp workspace")?; + let path = workspace.path().to_path_buf(); + + let memory: Arc = Arc::new(NoopMemory); + + let agent = Agent::builder() + .provider(Box::new(MockProvider)) + .tools(vec![Box::new(EchoTool)]) + .memory(memory) + .tool_dispatcher(Box::new(NativeToolDispatcher)) + .model_name("bench-mock".into()) + .agent_definition_name(format!("bench-{i}")) + .workspace_dir(path.clone()) + .action_dir(path) + .auto_save(false) + .build() + .context("build bench agent")?; + agents.push(agent); + workspaces.push(workspace); + } + Ok(Roster { + agents, + _workspaces: workspaces, + }) +} + +/// One deterministic warm-up turn per agent, forcing first-touch allocations +/// (prompt build, tokenizer, provider adapter) to fault in before measuring. +async fn warm_up(roster: &mut Roster) -> Result<()> { + for agent in &mut roster.agents { + let _ = agent.turn("warmup").await.context("warm-up turn")?; + } + Ok(()) +} + +/// Poll RSS until it stops climbing (Δ < 256 KiB between reads ~200 ms apart) +/// or a 2 s cap, draining async task-allocation jitter. No-op off Linux. +async fn settle() { + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let mut last = proc_metrics::sample_self().map(|s| s.rss_kib).unwrap_or(0); + loop { + tokio::time::sleep(Duration::from_millis(200)).await; + let now = proc_metrics::sample_self() + .map(|s| s.rss_kib) + .unwrap_or(last); + if now.abs_diff(last) < 256 || tokio::time::Instant::now() >= deadline { + break; + } + last = now; + } +} + +/// Child mode: build one roster in this fresh process, warm up, settle, sample, +/// and print the sample as a single JSON line on stdout. +async fn run_child(roster_size: usize) -> Result<()> { + // Diagnostics go to stderr so they never corrupt the single JSON line the + // parent parses from stdout. + eprintln!("[rss-bench] child: building {roster_size}-agent roster"); + let mut roster = build_roster(roster_size)?; + eprintln!("[rss-bench] child: warming up {roster_size} agent(s)"); + warm_up(&mut roster).await?; + eprintln!("[rss-bench] child: settling"); + settle().await; + let sample = proc_metrics::sample_self()?; + eprintln!( + "[rss-bench] child: sampled rss={}KiB threads={}", + sample.rss_kib, sample.threads + ); + println!("{}", serde_json::to_string(&sample)?); + drop(roster); // keep the roster alive until after the sample is taken + Ok(()) +} + +/// Parent mode: re-exec the child `repeat` times per roster size, aggregate, and +/// write the report. +async fn run_parent(out: Option, repeat: usize, roster_sizes: &[usize]) -> Result<()> { + let exe = std::env::current_exe().context("resolve current exe")?; + let mut rosters = Vec::with_capacity(roster_sizes.len()); + for &size in roster_sizes { + let mut samples = Vec::with_capacity(repeat); + for run in 0..repeat { + eprintln!("[rss-bench] spawn child roster={size} run={run}"); + // `kill_on_drop` + a `timeout` around `wait_with_output` gives a + // robust kill-and-reap: on timeout the cancelled future drops the + // child, `kill_on_drop` sends SIGKILL, and the tokio runtime reaps it. + let child = tokio::process::Command::new(&exe) + .arg("--child") + .arg("--roster") + .arg(size.to_string()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("spawn child roster={size} run={run}"))?; + let output = match tokio::time::timeout(CHILD_TIMEOUT, child.wait_with_output()).await { + Ok(res) => res.with_context(|| format!("await child roster={size} run={run}"))?, + Err(_) => { + eprintln!( + "[rss-bench] child roster={size} run={run} timed out after {}s; killed", + CHILD_TIMEOUT.as_secs() + ); + anyhow::bail!( + "child roster={size} run={run} timed out after {}s", + CHILD_TIMEOUT.as_secs() + ); + } + }; + if !output.status.success() { + eprintln!("[rss-bench] child roster={size} run={run} exited non-zero"); + anyhow::bail!( + "child roster={size} run={run} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let line = stdout + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .trim(); + let sample: ProcSample = serde_json::from_str(line) + .with_context(|| format!("parse child sample (roster={size}): {line:?}"))?; + eprintln!( + "[rss-bench] child roster={size} run={run} ok rss={}KiB", + sample.rss_kib + ); + samples.push(sample); + } + rosters.push(RosterResult::from_samples(size, samples)); + } + + let report = BenchReport { + schema_version: REPORT_SCHEMA_VERSION, + git_sha: git_sha(), + kernel: kernel(), + rss_budget_kib: RSS_BUDGET_KIB, + rss_hard_cap_kib: RSS_HARD_CAP_KIB, + rosters, + }; + + if let Some(path) = out { + std::fs::write(&path, serde_json::to_string_pretty(&report)?) + .with_context(|| format!("write report to {}", path.display()))?; + } + println!("{}", proc_metrics::human_summary(&report)); + Ok(()) +} + +/// Best-effort commit id for the report header (`GITHUB_SHA` in CI). +fn git_sha() -> String { + std::env::var("GITHUB_SHA").unwrap_or_else(|_| "unknown".into()) +} + +/// Best-effort kernel version for the report header. +fn kernel() -> String { + std::fs::read_to_string("/proc/sys/kernel/osrelease") + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| std::env::consts::OS.to_string()) +} + +/// Minimal flag parse — avoids a clap dependency for four flags. +struct Args { + child: bool, + roster: usize, + repeat: usize, + out: Option, +} + +fn parse_args() -> Result { + let mut child = false; + let mut roster = 1usize; + let mut repeat = DEFAULT_REPEAT; + let mut out = None; + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "--child" => child = true, + "--roster" => { + roster = it + .next() + .context("--roster needs a value")? + .parse() + .context("--roster value")?; + } + "--repeat" => { + repeat = it + .next() + .context("--repeat needs a value")? + .parse() + .context("--repeat value")?; + } + "--out" => out = Some(PathBuf::from(it.next().context("--out needs a path")?)), + other => anyhow::bail!("unknown argument: {other}"), + } + } + Ok(Args { + child, + roster, + repeat, + out, + }) +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = parse_args()?; + if args.child { + run_child(args.roster).await + } else { + run_parent(args.out, args.repeat, DEFAULT_ROSTER_SIZES).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_roster_constructs_bare_agents_with_isolated_workspaces() { + let roster = build_roster(8).expect("8-agent roster builds"); + assert_eq!(roster.agents.len(), 8); + assert_eq!(roster._workspaces.len(), 8); + // Each agent got a distinct workspace directory. + let mut dirs: Vec<_> = roster + ._workspaces + .iter() + .map(|w| w.path().to_path_buf()) + .collect(); + dirs.sort(); + dirs.dedup(); + assert_eq!(dirs.len(), 8, "workspaces must be isolated per agent"); + } + + #[tokio::test] + async fn warm_up_turn_completes_without_network() { + let mut roster = build_roster(1).expect("1-agent roster builds"); + warm_up(&mut roster).await.expect("warm-up turn completes"); + // The mock provider reports usage, so last_turn_usage is populated — + // proving the embedding cost-metering contract works on the bare Agent. + assert!( + roster.agents[0].last_turn_usage().is_some(), + "usage should be readable after a turn" + ); + } +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 3bff808817..72ac30aef8 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -96,6 +96,7 @@ pub mod orchestration; pub mod overlay; pub mod people; pub mod plan_review; +pub mod proc_metrics; pub mod profiles; pub mod prompt_injection; pub mod provider_surfaces; diff --git a/src/openhuman/proc_metrics/mod.rs b/src/openhuman/proc_metrics/mod.rs new file mode 100644 index 0000000000..44b0365310 --- /dev/null +++ b/src/openhuman/proc_metrics/mod.rs @@ -0,0 +1,446 @@ +//! Process memory sampling from Linux `/proc`. +//! +//! Reads the current process's resident-memory breakdown from +//! `/proc/self/smaps_rollup` + `/proc/self/status` and aggregates repeated +//! samples into a [`RosterResult`] / [`BenchReport`]. Written for the +//! `rss-bench` benchmark harness (#5046), which measures the steady-state RSS +//! of an embedded `openhuman_core` agent roster against the 20–30 MiB budget, +//! but [`sample_self`] is a general capability: any caller wanting this +//! process's RSS / PSS / private-page / peak-RSS figures on Linux can use it. +//! +//! The parsers ([`parse_status`], [`parse_smaps_rollup`]) are OS-agnostic and +//! take `&str`, so they are unit-tested without a live `/proc`. [`sample_self`] +//! is Linux-only and returns a structured error elsewhere — it never fabricates +//! a reading (a macOS local run fails loudly rather than emitting garbage). + +use serde::{Deserialize, Serialize}; + +/// Product budget for the embedded roster, in KiB (#5046). Target the agent +/// roster should land under. +pub const RSS_BUDGET_KIB: u64 = 20 * 1024; +/// Hard cap for the embedded roster, in KiB (#5046). Steady-state RSS above +/// this fails the (eventually blocking) CI gate. +pub const RSS_HARD_CAP_KIB: u64 = 30 * 1024; + +/// One resident-memory sample of a single process. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcSample { + /// Resident set size (`/proc/self/status` `VmRSS`). + pub rss_kib: u64, + /// Proportional set size (`/proc/self/smaps_rollup` `Pss`). + pub pss_kib: u64, + /// Private clean pages (`smaps_rollup` `Private_Clean`). + pub private_clean_kib: u64, + /// Private dirty pages (`smaps_rollup` `Private_Dirty`). + pub private_dirty_kib: u64, + /// Peak resident set size (`status` `VmHWM`). + pub vm_hwm_kib: u64, + /// Live thread count (`status` `Threads`). + pub threads: u64, + /// On-disk size of the running executable, in bytes. + pub binary_size_bytes: u64, +} + +/// Fields extracted from `/proc//status`. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct StatusFields { + pub vm_rss_kib: u64, + pub vm_hwm_kib: u64, + pub threads: u64, +} + +/// Fields extracted from `/proc//smaps_rollup`. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct SmapsRollupFields { + pub pss_kib: u64, + pub private_clean_kib: u64, + pub private_dirty_kib: u64, +} + +/// First whitespace-separated integer in a `/proc` value tail +/// (e.g. `"\t 1234 kB"` → `1234`). Zero when absent or unparsable. +fn first_u64(rest: &str) -> u64 { + rest.split_whitespace() + .next() + .and_then(|token| token.parse().ok()) + .unwrap_or(0) +} + +/// Parse the `VmRSS` / `VmHWM` / `Threads` lines out of `/proc//status`. +/// Missing keys stay zero. OS-agnostic — feed it the file contents. +pub fn parse_status(contents: &str) -> StatusFields { + let mut fields = StatusFields::default(); + for line in contents.lines() { + if let Some(rest) = line.strip_prefix("VmRSS:") { + fields.vm_rss_kib = first_u64(rest); + } else if let Some(rest) = line.strip_prefix("VmHWM:") { + fields.vm_hwm_kib = first_u64(rest); + } else if let Some(rest) = line.strip_prefix("Threads:") { + fields.threads = first_u64(rest); + } + } + fields +} + +/// Parse the `Pss` / `Private_Clean` / `Private_Dirty` lines out of +/// `/proc//smaps_rollup` (the pre-summed variant of `smaps`). Missing keys +/// stay zero. `strip_prefix` with the trailing colon avoids matching the +/// `Pss_Anon:` / `Pss_Dirty:` breakdown lines. +pub fn parse_smaps_rollup(contents: &str) -> SmapsRollupFields { + let mut fields = SmapsRollupFields::default(); + for line in contents.lines() { + if let Some(rest) = line.strip_prefix("Pss:") { + fields.pss_kib = first_u64(rest); + } else if let Some(rest) = line.strip_prefix("Private_Clean:") { + fields.private_clean_kib = first_u64(rest); + } else if let Some(rest) = line.strip_prefix("Private_Dirty:") { + fields.private_dirty_kib = first_u64(rest); + } + } + fields +} + +/// Sample this process's resident memory. Linux-only. +#[cfg(target_os = "linux")] +pub fn sample_self() -> anyhow::Result { + use anyhow::Context; + let status = std::fs::read_to_string("/proc/self/status").context("read /proc/self/status")?; + let smaps = std::fs::read_to_string("/proc/self/smaps_rollup") + .context("read /proc/self/smaps_rollup")?; + let status = parse_status(&status); + let smaps = parse_smaps_rollup(&smaps); + let binary_size_bytes = std::env::current_exe() + .and_then(std::fs::metadata) + .map(|meta| meta.len()) + .unwrap_or(0); + Ok(ProcSample { + rss_kib: status.vm_rss_kib, + pss_kib: smaps.pss_kib, + private_clean_kib: smaps.private_clean_kib, + private_dirty_kib: smaps.private_dirty_kib, + vm_hwm_kib: status.vm_hwm_kib, + threads: status.threads, + binary_size_bytes, + }) +} + +/// Sample this process's resident memory. Non-Linux stub — fails loudly rather +/// than fabricating a reading. +#[cfg(not(target_os = "linux"))] +pub fn sample_self() -> anyhow::Result { + anyhow::bail!( + "proc_metrics::sample_self requires Linux /proc/self/smaps_rollup + status (this is a {} build)", + std::env::consts::OS + ) +} + +/// Median of a slice of `u64`, averaging the two middle values for even counts. +/// Empty input yields zero. +fn median_u64(values: &[u64]) -> u64 { + if values.is_empty() { + return 0; + } + let mut sorted = values.to_vec(); + sorted.sort_unstable(); + let mid = sorted.len() / 2; + if sorted.len() % 2 == 1 { + sorted[mid] + } else { + // Average without overflow. + sorted[mid - 1] + (sorted[mid] - sorted[mid - 1]) / 2 + } +} + +/// Aggregated result for one roster size across several fresh-process samples. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RosterResult { + pub roster_size: usize, + pub sample_count: usize, + pub median_rss_kib: u64, + pub min_rss_kib: u64, + pub max_rss_kib: u64, + pub mean_rss_kib: u64, + pub median_pss_kib: u64, + pub max_vm_hwm_kib: u64, + pub median_threads: u64, + pub binary_size_bytes: u64, + /// The raw per-process samples, retained as a CI artifact. + pub samples: Vec, +} + +impl RosterResult { + /// Aggregate raw samples into the reported statistics. RSS is summarised as + /// median (steady-state), min/max/mean for spread; PSS/threads as median; + /// `VmHWM` as the max (peak) across processes. + pub fn from_samples(roster_size: usize, samples: Vec) -> Self { + let rss: Vec = samples.iter().map(|s| s.rss_kib).collect(); + let pss: Vec = samples.iter().map(|s| s.pss_kib).collect(); + let threads: Vec = samples.iter().map(|s| s.threads).collect(); + let count = samples.len(); + let mean_rss_kib = if count == 0 { + 0 + } else { + rss.iter().sum::() / count as u64 + }; + Self { + roster_size, + sample_count: count, + median_rss_kib: median_u64(&rss), + min_rss_kib: rss.iter().copied().min().unwrap_or(0), + max_rss_kib: rss.iter().copied().max().unwrap_or(0), + mean_rss_kib, + median_pss_kib: median_u64(&pss), + max_vm_hwm_kib: samples.iter().map(|s| s.vm_hwm_kib).max().unwrap_or(0), + median_threads: median_u64(&threads), + binary_size_bytes: samples.first().map(|s| s.binary_size_bytes).unwrap_or(0), + samples, + } + } +} + +/// The full benchmark report, serialized to the raw JSON CI artifact. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchReport { + pub schema_version: u32, + pub git_sha: String, + pub kernel: String, + pub rss_budget_kib: u64, + pub rss_hard_cap_kib: u64, + pub rosters: Vec, +} + +/// Current schema version for [`BenchReport`]; bump on any field change so +/// downstream trend tooling can detect format shifts. +pub const REPORT_SCHEMA_VERSION: u32 = 1; + +impl BenchReport { + /// Marginal steady-state RSS cost of each additional agent (#5046), derived + /// from the smallest and largest rosters measured: + /// `(median_rss(max) - median_rss(min)) / (max_size - min_size)`. + /// + /// Returns `(min_roster_size, max_roster_size, kib_per_agent)`, or `None` when + /// fewer than two distinct roster sizes were measured (no incremental cost is + /// derivable). For the default `{1, 8}` rosters this is the per-agent cost of + /// agents 2–8. + pub fn per_agent_increment_kib(&self) -> Option<(usize, usize, u64)> { + let min = self.rosters.iter().min_by_key(|r| r.roster_size)?; + let max = self.rosters.iter().max_by_key(|r| r.roster_size)?; + let span = max.roster_size.checked_sub(min.roster_size)?; + if span == 0 { + return None; + } + let per_agent = max.median_rss_kib.saturating_sub(min.median_rss_kib) / span as u64; + Some((min.roster_size, max.roster_size, per_agent)) + } +} + +fn kib_to_mib(kib: u64) -> f64 { + kib as f64 / 1024.0 +} + +/// Human-readable Markdown summary for stdout + `$GITHUB_STEP_SUMMARY`. +pub fn human_summary(report: &BenchReport) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + let _ = writeln!(out, "### Embedded `openhuman_core` RSS benchmark (#5046)"); + let _ = writeln!(out); + let _ = writeln!( + out, + "kernel `{}` · git `{}` · target ≤ {:.0} MiB · hard cap ≤ {:.0} MiB", + report.kernel, + report.git_sha, + kib_to_mib(report.rss_budget_kib), + kib_to_mib(report.rss_hard_cap_kib), + ); + let _ = writeln!(out); + let _ = writeln!( + out, + "| roster | n | median RSS | min–max RSS | median PSS | peak VmHWM | threads | binary |" + ); + let _ = writeln!( + out, + "| ------ | - | ---------- | ----------- | ---------- | ---------- | ------- | ------ |" + ); + for r in &report.rosters { + let over = if r.median_rss_kib > report.rss_hard_cap_kib { + " ⚠️" + } else { + "" + }; + let _ = writeln!( + out, + "| {} agent{} | {} | {:.1} MiB{} | {:.1}–{:.1} MiB | {:.1} MiB | {:.1} MiB | {} | {:.1} MiB |", + r.roster_size, + if r.roster_size == 1 { "" } else { "s" }, + r.sample_count, + kib_to_mib(r.median_rss_kib), + over, + kib_to_mib(r.min_rss_kib), + kib_to_mib(r.max_rss_kib), + kib_to_mib(r.median_pss_kib), + kib_to_mib(r.max_vm_hwm_kib), + r.median_threads, + r.binary_size_bytes as f64 / (1024.0 * 1024.0), + ); + } + if let Some((min_size, max_size, per_agent_kib)) = report.per_agent_increment_kib() { + let _ = writeln!(out); + let _ = writeln!( + out, + "Per-agent increment (roster {min_size}→{max_size}): {:.2} MiB/agent", + kib_to_mib(per_agent_kib), + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_STATUS: &str = "Name:\trss-bench\nVmPeak:\t 123456 kB\nVmRSS:\t 20480 kB\nVmHWM:\t 24576 kB\nThreads:\t8\n"; + + const SAMPLE_SMAPS_ROLLUP: &str = "00400000-7fff00000000 ---p 00000000 00:00 0 [rollup]\nRss:\t 20480 kB\nPss:\t 18000 kB\nPss_Anon:\t 1000 kB\nPss_Dirty:\t 500 kB\nPrivate_Clean:\t 4096 kB\nPrivate_Dirty:\t 12000 kB\n"; + + #[test] + fn parse_status_extracts_rss_hwm_threads() { + let f = parse_status(SAMPLE_STATUS); + assert_eq!(f.vm_rss_kib, 20480); + assert_eq!(f.vm_hwm_kib, 24576); + assert_eq!(f.threads, 8); + } + + #[test] + fn parse_status_missing_keys_stay_zero() { + let f = parse_status("Name:\tx\nState:\tR\n"); + assert_eq!(f, StatusFields::default()); + } + + #[test] + fn parse_smaps_rollup_extracts_pss_and_private_pages() { + let f = parse_smaps_rollup(SAMPLE_SMAPS_ROLLUP); + assert_eq!(f.pss_kib, 18000); + assert_eq!(f.private_clean_kib, 4096); + assert_eq!(f.private_dirty_kib, 12000); + } + + #[test] + fn parse_smaps_rollup_does_not_match_pss_breakdown_lines() { + // `Pss_Anon:` / `Pss_Dirty:` must not be read as `Pss:`. + let f = parse_smaps_rollup("Pss_Anon:\t 9999 kB\nPss_Dirty:\t 8888 kB\n"); + assert_eq!(f.pss_kib, 0); + } + + fn sample(rss: u64, pss: u64, hwm: u64, threads: u64) -> ProcSample { + ProcSample { + rss_kib: rss, + pss_kib: pss, + private_clean_kib: 0, + private_dirty_kib: 0, + vm_hwm_kib: hwm, + threads, + binary_size_bytes: 1024, + } + } + + #[test] + fn median_handles_odd_and_even() { + assert_eq!(median_u64(&[]), 0); + assert_eq!(median_u64(&[5]), 5); + assert_eq!(median_u64(&[3, 1, 2]), 2); + assert_eq!(median_u64(&[1, 2, 3, 4]), 2); // (2+3)/2 floored -> 2 + } + + #[test] + fn from_samples_aggregates_rss_pss_and_peak() { + let samples = vec![ + sample(20000, 18000, 21000, 8), + sample(22000, 19000, 26000, 8), + sample(21000, 18500, 24000, 8), + ]; + let r = RosterResult::from_samples(8, samples); + assert_eq!(r.roster_size, 8); + assert_eq!(r.sample_count, 3); + assert_eq!(r.median_rss_kib, 21000); + assert_eq!(r.min_rss_kib, 20000); + assert_eq!(r.max_rss_kib, 22000); + assert_eq!(r.mean_rss_kib, 21000); + assert_eq!(r.max_vm_hwm_kib, 26000); // peak across processes + assert_eq!(r.median_threads, 8); + } + + #[test] + fn report_serde_round_trips() { + let report = BenchReport { + schema_version: REPORT_SCHEMA_VERSION, + git_sha: "abc123".into(), + kernel: "6.1.0".into(), + rss_budget_kib: RSS_BUDGET_KIB, + rss_hard_cap_kib: RSS_HARD_CAP_KIB, + rosters: vec![RosterResult::from_samples( + 1, + vec![sample(15000, 14000, 16000, 6)], + )], + }; + let json = serde_json::to_string(&report).unwrap(); + let back: BenchReport = serde_json::from_str(&json).unwrap(); + assert_eq!(back.rosters.len(), 1); + assert_eq!(back.rosters[0].samples[0], report.rosters[0].samples[0]); + assert_eq!(back.rss_hard_cap_kib, RSS_HARD_CAP_KIB); + } + + #[test] + fn human_summary_flags_over_cap_roster() { + let report = BenchReport { + schema_version: REPORT_SCHEMA_VERSION, + git_sha: "deadbeef".into(), + kernel: "6.1.0".into(), + rss_budget_kib: RSS_BUDGET_KIB, + rss_hard_cap_kib: RSS_HARD_CAP_KIB, + rosters: vec![RosterResult::from_samples( + 8, + vec![sample(40000, 30000, 42000, 12)], + )], + }; + let summary = human_summary(&report); + assert!(summary.contains("8 agents")); + assert!(summary.contains("⚠️"), "over-cap roster must be flagged"); + } + + #[test] + fn per_agent_increment_from_min_and_max_rosters() { + let report = BenchReport { + schema_version: REPORT_SCHEMA_VERSION, + git_sha: "x".into(), + kernel: "6.1.0".into(), + rss_budget_kib: RSS_BUDGET_KIB, + rss_hard_cap_kib: RSS_HARD_CAP_KIB, + rosters: vec![ + RosterResult::from_samples(1, vec![sample(20_000, 0, 0, 6)]), + RosterResult::from_samples(8, vec![sample(27_000, 0, 0, 6)]), + ], + }; + // (27000 - 20000) / (8 - 1) = 1000 KiB per agent. + assert_eq!(report.per_agent_increment_kib(), Some((1, 8, 1000))); + assert!(human_summary(&report).contains("Per-agent increment (roster 1→8)")); + } + + #[test] + fn per_agent_increment_none_for_single_roster() { + let report = BenchReport { + schema_version: REPORT_SCHEMA_VERSION, + git_sha: "x".into(), + kernel: "6.1.0".into(), + rss_budget_kib: RSS_BUDGET_KIB, + rss_hard_cap_kib: RSS_HARD_CAP_KIB, + rosters: vec![RosterResult::from_samples(1, vec![sample(20_000, 0, 0, 6)])], + }; + assert_eq!(report.per_agent_increment_kib(), None); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn sample_self_is_linux_only() { + assert!(sample_self().is_err()); + } +} From 4019a9a18b1e907a9903104ffd79abaad6f73799 Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:33:22 +0530 Subject: [PATCH 03/72] feat(core): gate the desktop-automation cluster behind a default-ON feature (#5049) (#5061) Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> --- .github/workflows/ci-lite.yml | 3 +- Cargo.toml | 27 +- app/src-tauri/Cargo.toml | 1 + src/core/all.rs | 7 +- src/core/all_tests.rs | 36 +++ src/core/autocomplete_cli_adapter.rs | 26 +- src/core/cli_tests.rs | 3 + src/core/jsonrpc_tests.rs | 4 + src/core/legacy_aliases.rs | 32 ++- src/core/runtime/builder.rs | 10 + src/openhuman/accessibility/globe.rs | 28 +- src/openhuman/accessibility/mod.rs | 66 ++++- src/openhuman/accessibility/stub.rs | 248 ++++++++++++++++++ src/openhuman/accessibility/types.rs | 23 ++ src/openhuman/autocomplete/core/engine.rs | 14 +- src/openhuman/autocomplete/core/mod.rs | 12 +- src/openhuman/autocomplete/core/text.rs | 2 +- src/openhuman/autocomplete/mod.rs | 37 +++ src/openhuman/autocomplete/stub.rs | 79 ++++++ .../autocomplete/{core => }/types.rs | 7 +- src/openhuman/desktop_companion/mod.rs | 24 +- src/openhuman/desktop_companion/stub.rs | 20 ++ src/openhuman/screen_intelligence/mod.rs | 43 ++- src/openhuman/screen_intelligence/stub.rs | 178 +++++++++++++ src/openhuman/tools/impl/mod.rs | 6 + src/openhuman/tools/impl/system/mod.rs | 5 +- src/openhuman/tools/mod.rs | 1 + src/openhuman/tools/ops.rs | 41 ++- src/openhuman/tools/ops_tests.rs | 55 ++++ 29 files changed, 975 insertions(+), 63 deletions(-) create mode 100644 src/openhuman/accessibility/stub.rs create mode 100644 src/openhuman/autocomplete/stub.rs rename src/openhuman/autocomplete/{core => }/types.rs (88%) create mode 100644 src/openhuman/desktop_companion/stub.rs create mode 100644 src/openhuman/screen_intelligence/stub.rs diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 4cfd70c64d..6697cb8f02 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -443,6 +443,7 @@ jobs: set -euo pipefail EXPECTED=$(cat <<'EOF' core/all_tests.rs + core/autocomplete_cli_adapter.rs core/cli_tests.rs core/jsonrpc_tests.rs core/legacy_aliases.rs @@ -462,7 +463,7 @@ jobs: openhuman/x402/stub.rs EOF ) - ACTUAL=$(grep -rlE '#\[cfg\((not\()?feature = "(voice|media|web3|meet|mcp|skills|flows)"' src --include='*.rs' \ + ACTUAL=$(grep -rlE '#\[cfg\((not\()?feature = "(voice|media|web3|meet|mcp|skills|flows|desktop-automation)"' 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)." diff --git a/Cargo.toml b/Cargo.toml index 4dcbbf1914..c3251f5794 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -297,7 +297,7 @@ windows-sys = { version = "0.61", features = [ # Microsoft UI Automation (UIA) bindings — the Windows backend for the # `ax_interact` tool (`accessibility::uia_interact`). Safe Rust wrappers over # the UIA COM API; the Windows analogue of the macOS AXUIElement Swift helper. -uiautomation = "0.25" +uiautomation = { version = "0.25", optional = true } [target.'cfg(not(windows))'.dependencies] # macOS / Linux: keep rustls + Mozilla webpki-roots — the historical @@ -338,7 +338,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp", "tui"] +default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp", "desktop-automation", "tui"] # AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build). # On by default; disable to fall back to the brace-depth heuristic. tokenjuice-treesitter = [ @@ -449,6 +449,28 @@ skills = [] # `sanitize::sanitize_for_llm`). The gate follows the real dependency graph, # not the directory name. mcp = [] + +# Desktop-automation cluster (#5049): the five modules that read/drive the local +# desktop UI — `openhuman::accessibility` (macOS AX / Windows UIA FFI middleware), +# `openhuman::screen_intelligence` (capture + vision loop), `openhuman::autocomplete` +# (inline completion), `openhuman::desktop_companion` (Clicky-style loop), and the +# `computer` agent-tool family (`ax_interact` / `automate` / mouse / keyboard). +# Default-ON — the desktop app always ships with these. Slim / headless builds opt +# out via `--no-default-features --features ""`, +# which drops the exclusive `uiautomation` (Windows UI Automation COM bindings) +# dependency. Composes with the runtime `DomainSet::desktop_automation` flag: the +# feature narrows the compile-time surface, `DomainSet` gates it at runtime. +# +# CARVE-OUT: the inert type modules stay compiled in BOTH builds — +# `accessibility::types` (incl. the `GlobeHotkey*` structs), `autocomplete::types` +# (`AutocompleteStatus`), and `screen_intelligence::types` (`AccessibilityStatus`, +# `CaptureImageRefResult`) — because always-on callers (`text_input`, `voice`, +# `app_state`) name them. Only behaviour is gated; the stubs re-export the one real +# type definition. See AGENTS.md "skills gate — the type carve-out". +# +# NOTE: only `uiautomation` is exclusive and thus shed. `enigo` is shared with the +# `voice` domain; `rdev` / `arboard` are voice-owned — none of those are dropped here. +desktop-automation = ["dep:uiautomation"] # Terminal chat UI: the `openhuman tui` (alias `chat`) CLI subcommand, a # ratatui/crossterm terminal front-end onto the same `web_chat` surface the # desktop app drives. Default-ON for the standalone `openhuman-core` binary, but @@ -462,6 +484,7 @@ mcp = [] # build-fact "tui feature disabled at compile time" error from the untouched # `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern). tui = ["dep:ratatui", "dep:crossterm", "dep:unicode-width"] + sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 16fe16764f..97dd397d4e 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -169,6 +169,7 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal "meet", "skills", "mcp", + "desktop-automation", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/src/core/all.rs b/src/core/all.rs index 6fd7fd648e..9374b78bcf 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -82,6 +82,7 @@ pub enum DomainGroup { Web3, Voice, Media, + DesktopAutomation, // Everything not in a named family — always on in `full()`, off otherwise. Platform, } @@ -375,7 +376,7 @@ fn build_registered_controllers() -> Vec { // Inline autocomplete settings push( &mut controllers, - DomainGroup::Platform, + DomainGroup::DesktopAutomation, crate::openhuman::autocomplete::all_autocomplete_registered_controllers(), ); // External messaging channels (Web, Telegram, etc.) @@ -464,7 +465,7 @@ fn build_registered_controllers() -> Vec { // Screen capture and UI analysis push( &mut controllers, - DomainGroup::Platform, + DomainGroup::DesktopAutomation, crate::openhuman::screen_intelligence::all_screen_intelligence_registered_controllers(), ); // Sandbox execution backends (Docker, local jail, policy, cleanup) @@ -735,7 +736,7 @@ fn build_registered_controllers() -> Vec { // Desktop companion — Clicky-style interaction loop. push( &mut controllers, - DomainGroup::Platform, + DomainGroup::DesktopAutomation, crate::openhuman::desktop_companion::all_desktop_companion_registered_controllers(), ); // Structured WhatsApp Web data — agent-facing read-only controllers (list/search). diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 5667eabeb6..cdad5265ce 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -1094,3 +1094,39 @@ fn meet_controllers_absent_when_feature_off() { ); } } + +/// All three desktop-automation namespaces register under +/// `DomainGroup::DesktopAutomation` when the `desktop-automation` feature is on +/// (#5049). Paired with `desktop_automation_controllers_absent_when_feature_off` +/// below: together they pin both directions of the compile-time gate. +#[cfg(feature = "desktop-automation")] +#[test] +fn desktop_automation_controllers_registered_when_feature_on() { + for ns in ["autocomplete", "screen_intelligence", "companion"] { + assert_eq!( + group_for_namespace(ns), + Some(DomainGroup::DesktopAutomation), + "`{ns}` must register under DomainGroup::DesktopAutomation when the \ + `desktop-automation` feature is on" + ); + } +} + +/// Negative half of the `desktop-automation` gate (#5049): with the cluster +/// compiled out, none of the `autocomplete` / `screen_intelligence` / `companion` +/// controllers register (unknown-method over `/rpc`, absent from `/schema`). +/// Pairs with `desktop_automation_controllers_registered_when_feature_on` above. +/// The `screen_intelligence_*` tool-absence half lives in +/// `tools::ops_tests::screen_intelligence_tools_absent_when_feature_off`, where +/// the full agent tool list can be built. +#[cfg(not(feature = "desktop-automation"))] +#[test] +fn desktop_automation_controllers_absent_when_feature_off() { + for ns in ["autocomplete", "screen_intelligence", "companion"] { + assert_eq!( + group_for_namespace(ns), + None, + "`{ns}` must not register when the `desktop-automation` feature is off" + ); + } +} diff --git a/src/core/autocomplete_cli_adapter.rs b/src/core/autocomplete_cli_adapter.rs index 53ba0488da..9271377850 100644 --- a/src/core/autocomplete_cli_adapter.rs +++ b/src/core/autocomplete_cli_adapter.rs @@ -5,6 +5,7 @@ use anyhow::Result; use crate::core::logging::CliLogDefault; +#[cfg(feature = "desktop-automation")] use crate::openhuman::autocomplete::ops::{autocomplete_start_cli, AutocompleteStartCliOptions}; pub struct NamespacePreparse { @@ -75,6 +76,7 @@ pub fn maybe_print_start_help(namespace: &str, function: &str) -> bool { } } +#[cfg(feature = "desktop-automation")] pub fn maybe_handle_namespace_start( namespace: &str, function: &str, @@ -94,7 +96,27 @@ pub fn maybe_handle_namespace_start( Ok(Some(value)) } +/// Disabled-build variant: the `autocomplete` engine is compiled out, so +/// `autocomplete start` reports the build fact rather than silently succeeding. +#[cfg(not(feature = "desktop-automation"))] +pub fn maybe_handle_namespace_start( + namespace: &str, + function: &str, + _args: &[String], +) -> Result> { + if namespace != "autocomplete" || function != "start" { + return Ok(None); + } + log::debug!( + "[autocomplete] `autocomplete start` rejected: desktop-automation disabled at compile time" + ); + Err(anyhow::anyhow!( + "autocomplete is disabled in this build (rebuild with --features desktop-automation)" + )) +} + /// Parses CLI options specific to the `autocomplete start` command. +#[cfg(feature = "desktop-automation")] fn parse_autocomplete_start_cli_options(args: &[String]) -> Result { let mut debounce_ms: Option = None; let mut serve = false; @@ -149,10 +171,10 @@ fn print_autocomplete_start_help() { #[cfg(test)] mod tests { - use super::parse_autocomplete_start_cli_options; - + #[cfg(feature = "desktop-automation")] #[test] fn parse_autocomplete_start_cli_options_rejects_serve_and_spawn() { + use super::parse_autocomplete_start_cli_options; let args = vec!["--serve".to_string(), "--spawn".to_string()]; let err = parse_autocomplete_start_cli_options(&args) .expect_err("must reject mutually exclusive flags"); diff --git a/src/core/cli_tests.rs b/src/core/cli_tests.rs index 2b8f94d63e..3354a333be 100644 --- a/src/core/cli_tests.rs +++ b/src/core/cli_tests.rs @@ -19,6 +19,9 @@ fn grouped_schemas_contains_migrated_namespaces() { assert!(grouped.contains_key("doctor")); assert!(grouped.contains_key("encrypt")); assert!(grouped.contains_key("decrypt")); + // `autocomplete` is gated behind `desktop-automation` (#5049); only present + // when that feature is enabled (it is in the default/shipped build). + #[cfg(feature = "desktop-automation")] assert!(grouped.contains_key("autocomplete")); assert!(grouped.contains_key("config")); assert!(grouped.contains_key("auth")); diff --git a/src/core/jsonrpc_tests.rs b/src/core/jsonrpc_tests.rs index c711fc3ace..8b189229a2 100644 --- a/src/core/jsonrpc_tests.rs +++ b/src/core/jsonrpc_tests.rs @@ -295,6 +295,10 @@ async fn invoke_config_get_runtime_flags_via_registry() { assert!(result.get("result").is_some()); } +// `autocomplete_*` is gated behind `desktop-automation` (#5049); with the gate +// off the controller is unregistered, so this param-validation test only applies +// when the feature is enabled (it is in the default/shipped build). +#[cfg(feature = "desktop-automation")] #[tokio::test] async fn invoke_autocomplete_status_rejects_unknown_param() { let err = invoke_method( diff --git a/src/core/legacy_aliases.rs b/src/core/legacy_aliases.rs index f08bf5850d..ba293b5d5d 100644 --- a/src/core/legacy_aliases.rs +++ b/src/core/legacy_aliases.rs @@ -364,16 +364,30 @@ mod tests { /// /// Mirrors how the agent loader tolerates the orchestrator TOML's dangling /// `mcp_agent` subagent id (#4799). - #[cfg(feature = "mcp")] - fn is_compiled_out_method(_method: &str) -> bool { - false - } - - #[cfg(not(feature = "mcp"))] fn is_compiled_out_method(method: &str) -> bool { - // `mcp` feature OFF ⇒ the `mcp_clients` (dynamic registry) and - // `mcp_audit` (write log) controllers are unregistered. - method.starts_with("openhuman.mcp_clients_") || method.starts_with("openhuman.mcp_audit_") + // Each compile-time gate contributes the RPC namespaces whose controllers + // it unregisters. The frontend catalog is data (it always names the full + // shipped surface), so a slim build must ignore exactly those methods and + // keep asserting on everything else. + #[cfg(not(feature = "mcp"))] + // `mcp` OFF ⇒ the `mcp_clients` (dynamic registry) + `mcp_audit` (write + // log) controllers are unregistered. + if method.starts_with("openhuman.mcp_clients_") + || method.starts_with("openhuman.mcp_audit_") + { + return true; + } + #[cfg(not(feature = "desktop-automation"))] + // `desktop-automation` OFF ⇒ the autocomplete / screen_intelligence / + // desktop-companion controllers are unregistered (#5049). + if method.starts_with("openhuman.autocomplete_") + || method.starts_with("openhuman.screen_intelligence_") + || method.starts_with("openhuman.companion_") + { + return true; + } + let _ = method; + false } #[test] diff --git a/src/core/runtime/builder.rs b/src/core/runtime/builder.rs index d0188971a4..8af72b7d30 100644 --- a/src/core/runtime/builder.rs +++ b/src/core/runtime/builder.rs @@ -160,6 +160,9 @@ pub struct DomainSet { /// future backing controller would stay live. Fold the media-generation /// controller into this group when it lands. pub media: bool, + /// Accessibility middleware, screen intelligence, inline autocomplete, the + /// desktop companion loop, and the `computer` agent-tool family. + pub desktop_automation: bool, /// Everything not in a named family — always on in `full()`. pub platform: bool, } @@ -182,6 +185,7 @@ impl DomainSet { web3: true, voice: true, media: true, + desktop_automation: true, platform: true, } } @@ -204,6 +208,7 @@ impl DomainSet { web3: false, voice: false, media: false, + desktop_automation: false, platform: false, } } @@ -224,6 +229,7 @@ impl DomainSet { web3: false, voice: false, media: false, + desktop_automation: false, platform: false, } } @@ -244,6 +250,7 @@ impl DomainSet { DomainGroup::Web3 => self.web3, DomainGroup::Voice => self.voice, DomainGroup::Media => self.media, + DomainGroup::DesktopAutomation => self.desktop_automation, DomainGroup::Platform => self.platform, } } @@ -594,6 +601,7 @@ mod tests { DomainGroup::Web3, DomainGroup::Voice, DomainGroup::Media, + DomainGroup::DesktopAutomation, DomainGroup::Platform, ] { assert!(full.allows(group), "full() must allow {group:?}"); @@ -620,6 +628,7 @@ mod tests { DomainGroup::Web3, DomainGroup::Voice, DomainGroup::Media, + DomainGroup::DesktopAutomation, DomainGroup::Platform, ] { assert!(!harness.allows(off), "harness() must NOT allow {off:?}"); @@ -641,6 +650,7 @@ mod tests { DomainGroup::Web3, DomainGroup::Voice, DomainGroup::Media, + DomainGroup::DesktopAutomation, DomainGroup::Platform, ] { assert!(!none.allows(group), "none() must NOT allow {group:?}"); diff --git a/src/openhuman/accessibility/globe.rs b/src/openhuman/accessibility/globe.rs index e5a440e39c..60ee75e3ff 100644 --- a/src/openhuman/accessibility/globe.rs +++ b/src/openhuman/accessibility/globe.rs @@ -3,7 +3,14 @@ //! The listener runs as a tiny Swift process that monitors `flagsChanged` //! events globally and reports `FN_DOWN` / `FN_UP` lines over stdout. -use super::{detect_permissions, PermissionState}; +use super::detect_permissions; +// `PermissionState` (the type) is named only by the macOS listener's permission +// check; the non-macOS stubs read `detect_permissions().input_monitoring` without +// naming the type. Gating the import keeps the Linux/Windows build warning-clean +// (the `GlobeHotkey*` structs that referenced it unconditionally now live in +// `super::types`). +#[cfg(target_os = "macos")] +use super::PermissionState; #[cfg(target_os = "macos")] use std::collections::VecDeque; @@ -23,20 +30,11 @@ use std::sync::{Arc, Mutex as StdMutex}; const LOG_PREFIX: &str = "[globe_hotkey]"; const MAX_PENDING_EVENTS: usize = 64; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct GlobeHotkeyStatus { - pub supported: bool, - pub running: bool, - pub input_monitoring_permission: PermissionState, - pub last_error: Option, - pub events_pending: usize, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct GlobeHotkeyPollResult { - pub status: GlobeHotkeyStatus, - pub events: Vec, -} +// The inert status/result structs live in `types.rs` (dep-free, no FFI) so they +// stay compiled when `desktop-automation` is off. Re-export here so existing +// `accessibility::globe::{GlobeHotkeyStatus, GlobeHotkeyPollResult}` paths and +// the `accessibility::mod.rs` re-export keep resolving. +pub use super::types::{GlobeHotkeyPollResult, GlobeHotkeyStatus}; #[cfg(target_os = "macos")] struct GlobeListenerProcess { diff --git a/src/openhuman/accessibility/mod.rs b/src/openhuman/accessibility/mod.rs index f690fe577b..9ae78cf68c 100644 --- a/src/openhuman/accessibility/mod.rs +++ b/src/openhuman/accessibility/mod.rs @@ -4,66 +4,114 @@ //! Centralises all macOS AX/CGEvent/IOKit FFI and the unified Swift helper process. //! Consumer modules (autocomplete, screen_intelligence, voice) call into this module //! instead of owning platform-specific code directly. +//! +//! Facade for the `desktop-automation` gate (#5049): the FFI-bearing submodules are +//! `#[cfg(feature = "desktop-automation")]`; the inert `types` module (and the +//! `GlobeHotkey*` structs it now owns) stay compiled in both directions so the +//! always-on callers (`text_input`, `voice`, `app_state`, …) keep the one real type +//! definition. When the feature is off, `stub` re-exposes the public *behaviour* +//! surface those callers reach, with disabled-error / no-op / denied bodies. + +// Inert, dependency-free platform types — compiled in BOTH builds (type carve-out). +// The `GlobeHotkeyStatus` / `GlobeHotkeyPollResult` structs live here too (moved out +// of the FFI `globe` module, which re-exports them) so they resolve with the feature +// off. See AGENTS.md "skills gate — the type carve-out". +pub mod types; +#[cfg(feature = "desktop-automation")] pub mod app_fastpaths; +#[cfg(feature = "desktop-automation")] pub mod automate; +#[cfg(feature = "desktop-automation")] mod automation_state; +#[cfg(feature = "desktop-automation")] pub mod ax_interact; +#[cfg(feature = "desktop-automation")] mod capture; // Pure ranked/normalized matching of listed AX elements against a target label // — the "reliable UI element clicking" selection primitive (no FFI). Consumed by // `ax_interact.rs` to order filtered results best-first. +#[cfg(feature = "desktop-automation")] mod element_match; +#[cfg(feature = "desktop-automation")] mod focus; +#[cfg(feature = "desktop-automation")] mod globe; +#[cfg(feature = "desktop-automation")] mod helper; +#[cfg(feature = "desktop-automation")] mod keys; +#[cfg(feature = "desktop-automation")] mod overlay; +#[cfg(feature = "desktop-automation")] mod paste; +#[cfg(feature = "desktop-automation")] mod permissions; +#[cfg(feature = "desktop-automation")] mod terminal; +#[cfg(feature = "desktop-automation")] mod text_util; -mod types; // Vision fallback for `automate`: screenshot → vision-locate → guarded click, // for Electron/partial-AX apps. Consumed by `automate.rs`'s `RealBackend`. +#[cfg(feature = "desktop-automation")] mod vision_click; // Windows accessibility backend for `ax_interact` (UI Automation). Sibling of // the macOS Swift-helper path; selected via cfg-dispatch in `ax_interact.rs`. -#[cfg(target_os = "windows")] +#[cfg(all(feature = "desktop-automation", target_os = "windows"))] mod uia_interact; +#[cfg(not(feature = "desktop-automation"))] +mod stub; +#[cfg(not(feature = "desktop-automation"))] +pub use stub::*; + +#[cfg(feature = "desktop-automation")] pub use automation_state::{ clear as clear_automation_denial, mark_system_events_denied, system_events_denied, }; +#[cfg(feature = "desktop-automation")] pub use capture::{capture_screen_image_ref_for_context, CaptureMode, MAX_SCREENSHOT_BYTES}; +#[cfg(feature = "desktop-automation")] pub use element_match::{best_match, ElementMatch, MatchTier}; +#[cfg(feature = "desktop-automation")] pub use focus::{ focused_text_context, focused_text_context_verbose, foreground_context, parse_foreground_output, validate_focused_target, }; -pub use globe::{ - globe_listener_poll, globe_listener_start, globe_listener_stop, GlobeHotkeyPollResult, - GlobeHotkeyStatus, -}; +#[cfg(feature = "desktop-automation")] +pub use globe::{globe_listener_poll, globe_listener_start, globe_listener_stop}; +#[cfg(feature = "desktop-automation")] pub use helper::precompile_helper_background; +#[cfg(feature = "desktop-automation")] pub use keys::{any_modifier_down, is_escape_key_down, is_tab_key_down}; +#[cfg(feature = "desktop-automation")] pub use overlay::{hide_overlay, quit_overlay, show_overlay}; +#[cfg(feature = "desktop-automation")] pub use paste::{apply_text_to_focused_field, send_backspace}; -#[cfg(target_os = "macos")] +#[cfg(all(feature = "desktop-automation", target_os = "macos"))] pub use permissions::{ detect_accessibility_permission, detect_input_monitoring_permission, detect_screen_recording_permission, open_macos_privacy_pane, request_accessibility_access, request_screen_recording_access, }; +#[cfg(feature = "desktop-automation")] pub use permissions::{ detect_microphone_permission, detect_permissions, microphone_denied_message, permission_to_str, request_microphone_access, }; +#[cfg(feature = "desktop-automation")] pub use terminal::{ extract_terminal_input_context, is_terminal_app, is_text_role, looks_like_terminal_buffer, }; +#[cfg(feature = "desktop-automation")] pub use text_util::{normalize_ax_value, parse_ax_number, truncate_tail}; + +// Carved types — compiled in BOTH builds. The `GlobeHotkey*` structs moved out of +// the FFI `globe` module into `types`; re-export them here (ungated) so +// `screen_intelligence::types` and other carved consumers resolve in both builds. +// With the feature ON, `globe` also re-exports them, so the enabled build keeps +// its historical `accessibility::globe::GlobeHotkeyStatus` path too. pub use types::{ - AppContext, ElementBounds, FocusedTextContext, PermissionKind, PermissionState, - PermissionStatus, + AppContext, ElementBounds, FocusedTextContext, GlobeHotkeyPollResult, GlobeHotkeyStatus, + PermissionKind, PermissionState, PermissionStatus, }; diff --git a/src/openhuman/accessibility/stub.rs b/src/openhuman/accessibility/stub.rs new file mode 100644 index 0000000000..09495d4ee3 --- /dev/null +++ b/src/openhuman/accessibility/stub.rs @@ -0,0 +1,248 @@ +//! Disabled-build stub for the `accessibility` domain (`desktop-automation` off). +//! +//! Reproduces the public *behaviour* surface that always-compiled / other-gated +//! callers reach — `text_input`, `voice`, `app_state`, `screen_intelligence` +//! (itself gated) — with disabled-error / no-op / denied bodies. The inert types +//! stay in `types.rs` and are re-exported by the facade, so there is zero type +//! duplication here (the carve-out from AGENTS.md). +//! +//! Signatures match the real ones byte-for-byte; the slim build +//! (`--no-default-features --features tokenjuice-treesitter`) is the only thing +//! that catches drift between this file and the real module. + +use super::types::{ + AppContext, ElementBounds, FocusedTextContext, GlobeHotkeyPollResult, GlobeHotkeyStatus, + PermissionKind, PermissionState, PermissionStatus, +}; + +const DISABLED: &str = + "desktop automation is disabled in this build (rebuild with --features desktop-automation)"; + +// ── focus ──────────────────────────────────────────────────────────────── + +/// Real: `focus::validate_focused_target`. The macOS arm inspects the AX tree; +/// the non-macOS arm returns `Ok(())`. With automation compiled out there is no +/// focus to validate, so treat it as inconclusive-pass (matching the non-macOS +/// real behaviour). +pub fn validate_focused_target( + _expected_app: Option<&str>, + _expected_role: Option<&str>, +) -> Result<(), String> { + Ok(()) +} + +/// Real: `focus::focused_text_context_verbose`. No AX backend when disabled. +pub fn focused_text_context_verbose() -> Result { + Err(DISABLED.to_string()) +} + +/// Real: `focus::focused_text_context`. +pub fn focused_text_context() -> Result { + Err(DISABLED.to_string()) +} + +/// Real: `focus::foreground_context`. +pub fn foreground_context() -> Option { + None +} + +// ── terminal ───────────────────────────────────────────────────────────── + +/// Real: `terminal::is_terminal_app`. Pure heuristic in the real module, but it +/// lives inside the gated tree; without automation there is no focused app, so +/// nothing is a terminal. +pub fn is_terminal_app(_app_name: Option<&str>) -> bool { + false +} + +// ── paste ──────────────────────────────────────────────────────────────── + +/// Real: `paste::apply_text_to_focused_field`. +pub fn apply_text_to_focused_field(_text: &str) -> Result<(), String> { + // Grep-friendly: never logs the text itself (PII). + log::debug!("[accessibility] apply_text_to_focused_field disabled: desktop-automation off"); + Err(DISABLED.to_string()) +} + +/// Real: `paste::send_backspace`. +pub fn send_backspace(_count: usize) -> Result<(), String> { + log::debug!("[accessibility] send_backspace disabled: desktop-automation off"); + Err(DISABLED.to_string()) +} + +// ── overlay ────────────────────────────────────────────────────────────── + +/// Real: `overlay::show_overlay`. The non-macOS real arm is a no-op `Ok(())`; +/// match it — with no overlay helper there is nothing to draw. +pub fn show_overlay( + _bounds: &ElementBounds, + _text: &str, + _ttl_ms: u32, + _tab_hint: &str, +) -> Result<(), String> { + Ok(()) +} + +/// Real: `overlay::hide_overlay`. +pub fn hide_overlay() -> Result<(), String> { + Ok(()) +} + +/// Real: `overlay::quit_overlay`. +pub fn quit_overlay() -> Result<(), String> { + Ok(()) +} + +// ── globe ──────────────────────────────────────────────────────────────── + +/// Real: `globe::globe_listener_start`. Re-exports the carved `GlobeHotkey*` +/// types; the listener helper is compiled out, so report unsupported. +pub fn globe_listener_start() -> Result { + log::debug!("[accessibility] globe_listener_start disabled: desktop-automation off"); + Ok(disabled_globe_status()) +} + +/// Real: `globe::globe_listener_poll`. +pub fn globe_listener_poll() -> Result { + Ok(GlobeHotkeyPollResult { + status: disabled_globe_status(), + events: Vec::new(), + }) +} + +/// Real: `globe::globe_listener_stop`. +pub fn globe_listener_stop() -> Result { + Ok(disabled_globe_status()) +} + +fn disabled_globe_status() -> GlobeHotkeyStatus { + GlobeHotkeyStatus { + supported: false, + running: false, + input_monitoring_permission: PermissionState::Unsupported, + last_error: Some(DISABLED.to_string()), + events_pending: 0, + } +} + +// ── permissions ────────────────────────────────────────────────────────── + +/// Real: `permissions::detect_microphone_permission`. Microphone capture lives in +/// the `voice` domain, which reads its permission through here — so this stub is +/// load-bearing when `voice` is enabled but `desktop-automation` is not. +/// +/// The real Linux / non-macOS implementation cpal-probes and returns `Granted` +/// when an input device is available; the voice recorder treats `Unknown` as +/// "request then re-check, else fail", so returning `Unknown` here would break +/// dictation before it ever opens the mic. Match the permissive non-desktop arm +/// (`Granted`) so voice proceeds and any real capture error surfaces from cpal. +pub fn detect_microphone_permission() -> PermissionState { + PermissionState::Granted +} + +/// Real: `permissions::request_microphone_access`. No-op when disabled. +pub fn request_microphone_access() {} + +/// Real: `permissions::microphone_denied_message`. +pub fn microphone_denied_message() -> String { + "Microphone permission could not be determined in this build.".to_string() +} + +/// Real: `permissions::permission_to_str`. +pub fn permission_to_str(permission: PermissionKind) -> &'static str { + match permission { + PermissionKind::ScreenRecording => "screen_recording", + PermissionKind::Accessibility => "accessibility", + PermissionKind::InputMonitoring => "input_monitoring", + PermissionKind::Microphone => "microphone", + } +} + +/// Real: `permissions::detect_permissions`. The desktop-automation permissions +/// (screen-recording / accessibility / input-monitoring) are `Unsupported` when +/// the gate is off — matching the real non-macOS `detect_permissions`, which +/// reports these as `Unsupported` so the disabled build "behaves like a +/// non-desktop platform". Microphone mirrors `detect_microphone_permission` +/// above so the `voice`-on / `desktop-automation`-off build keeps a usable mic +/// state. +pub fn detect_permissions() -> PermissionStatus { + PermissionStatus { + screen_recording: PermissionState::Unsupported, + accessibility: PermissionState::Unsupported, + input_monitoring: PermissionState::Unsupported, + microphone: PermissionState::Granted, + } +} + +// ── automation state ───────────────────────────────────────────────────── + +/// Real: `automation_state::mark_system_events_denied`. No shared denial state +/// exists when automation is compiled out, so these are no-ops / `false`. +pub fn mark_system_events_denied() {} + +/// Real: `automation_state::clear` (re-exported as `clear_automation_denial`). +pub fn clear_automation_denial() {} + +/// Real: `automation_state::system_events_denied`. +pub fn system_events_denied() -> bool { + false +} + +// ── automate ───────────────────────────────────────────────────────────── + +/// Disabled-build mirror of `accessibility::automate`. Reached by +/// `voice::always_on::execute_intent`, which builds a `RealBackend`, calls `run`, +/// and reads `.success` / `.summary` off the returned outcome. +pub mod automate { + use crate::openhuman::config::Config; + + use super::DISABLED; + + /// Real: `automate::AutomateOutcome`. + #[derive(Debug, Clone, PartialEq)] + pub struct AutomateOutcome { + pub success: bool, + pub summary: String, + pub steps: Vec, + } + + /// Real: `automate::AutomateOptions`. The real module hand-writes `Default` + /// (a non-zero `DEFAULT_STEP_BUDGET`); the disabled path never steps, so the + /// derived zero-default is correct here. + #[derive(Debug, Clone, Copy, Default)] + pub struct AutomateOptions { + pub step_budget: u32, + } + + /// Real: `automate::RealBackend`. The config is retained to match the real + /// constructor signature exactly, even though the disabled path never uses it. + pub struct RealBackend { + #[allow(dead_code)] + config: Config, + } + + impl RealBackend { + pub fn new(config: Config) -> Self { + Self { config } + } + } + + /// Real: `automate::run`. The disabled build has no backend to drive, so it + /// reports a failed outcome rather than performing any UI automation. The + /// generic `backend` parameter keeps the caller's `&RealBackend` argument + /// compiling without stubbing the `AutomateBackend` trait. + pub async fn run( + _app: &str, + _goal: &str, + _backend: &B, + _opts: AutomateOptions, + ) -> AutomateOutcome { + // Grep-friendly: never logs `_app` / `_goal` (may carry user content). + log::debug!("[accessibility] automate::run disabled: desktop-automation off"); + AutomateOutcome { + success: false, + summary: DISABLED.to_string(), + steps: Vec::new(), + } + } +} diff --git a/src/openhuman/accessibility/types.rs b/src/openhuman/accessibility/types.rs index a4baf60bd0..88da102680 100644 --- a/src/openhuman/accessibility/types.rs +++ b/src/openhuman/accessibility/types.rs @@ -77,6 +77,29 @@ pub enum PermissionKind { Microphone, } +/// Status of the macOS Globe/Fn key listener helper. +/// +/// Inert serde data — no FFI. Lives here (rather than in the FFI-bearing +/// `globe` module) so it stays compiled when the `desktop-automation` feature +/// is off; `globe` re-exports it so existing paths still resolve. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobeHotkeyStatus { + pub supported: bool, + pub running: bool, + pub input_monitoring_permission: PermissionState, + pub last_error: Option, + pub events_pending: usize, +} + +/// Result of polling the Globe/Fn listener: current status plus drained events. +/// +/// Inert serde data — see [`GlobeHotkeyStatus`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobeHotkeyPollResult { + pub status: GlobeHotkeyStatus, + pub events: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/autocomplete/core/engine.rs b/src/openhuman/autocomplete/core/engine.rs index 8792e5f88c..886f256b04 100644 --- a/src/openhuman/autocomplete/core/engine.rs +++ b/src/openhuman/autocomplete/core/engine.rs @@ -7,6 +7,13 @@ use tokio::sync::Mutex; use tokio::task::JoinHandle; use tokio::time::{self, Duration, Instant}; +use super::super::types::{ + AutocompleteAcceptParams, AutocompleteAcceptResult, AutocompleteCurrentParams, + AutocompleteCurrentResult, AutocompleteDebugFocusResult, AutocompleteSetStyleParams, + AutocompleteSetStyleResult, AutocompleteStartParams, AutocompleteStartResult, + AutocompleteStatus, AutocompleteStopParams, AutocompleteStopResult, AutocompleteSuggestion, + FocusedTextContext, +}; #[cfg(target_os = "macos")] use super::focus::validate_focused_target; use super::focus::{ @@ -20,13 +27,6 @@ use super::terminal::{ extract_terminal_input_context, is_terminal_app, looks_like_terminal_buffer, }; use super::text::{is_no_text_candidate_error, sanitize_suggestion, truncate_tail}; -use super::types::{ - AutocompleteAcceptParams, AutocompleteAcceptResult, AutocompleteCurrentParams, - AutocompleteCurrentResult, AutocompleteDebugFocusResult, AutocompleteSetStyleParams, - AutocompleteSetStyleResult, AutocompleteStartParams, AutocompleteStartResult, - AutocompleteStatus, AutocompleteStopParams, AutocompleteStopResult, AutocompleteSuggestion, - FocusedTextContext, -}; const REFRESH_TIMEOUT_SECS: u64 = 120; diff --git a/src/openhuman/autocomplete/core/mod.rs b/src/openhuman/autocomplete/core/mod.rs index dae2482151..aa8d55b138 100644 --- a/src/openhuman/autocomplete/core/mod.rs +++ b/src/openhuman/autocomplete/core/mod.rs @@ -5,12 +5,10 @@ mod focus; mod overlay; mod terminal; mod text; -mod types; pub use engine::{global_engine, start_if_enabled, AutocompleteEngine, AUTOCOMPLETE_ENGINE}; -pub use types::{ - AutocompleteAcceptParams, AutocompleteAcceptResult, AutocompleteCurrentParams, - AutocompleteCurrentResult, AutocompleteDebugFocusResult, AutocompleteSetStyleParams, - AutocompleteSetStyleResult, AutocompleteStartParams, AutocompleteStartResult, - AutocompleteStatus, AutocompleteStopParams, AutocompleteStopResult, AutocompleteSuggestion, -}; +// The inert request/response + status types live one level up in +// `autocomplete::types` (dep-free) and are re-exported by the `autocomplete` +// facade (ungated). `core`'s own code reaches them via `super::super::types`; the +// facade owns the public `autocomplete::{AutocompleteStatus, …}` re-export, so we +// do not re-export them here (that would collide with the facade's `pub use`). diff --git a/src/openhuman/autocomplete/core/text.rs b/src/openhuman/autocomplete/core/text.rs index d369166d0a..43c3c0b021 100644 --- a/src/openhuman/autocomplete/core/text.rs +++ b/src/openhuman/autocomplete/core/text.rs @@ -1,6 +1,6 @@ //! Text utilities for autocomplete suggestions. -use super::types::MAX_SUGGESTION_CHARS; +use super::super::types::MAX_SUGGESTION_CHARS; pub(super) use crate::openhuman::accessibility::truncate_tail; diff --git a/src/openhuman/autocomplete/mod.rs b/src/openhuman/autocomplete/mod.rs index 4866b99151..f522f69829 100644 --- a/src/openhuman/autocomplete/mod.rs +++ b/src/openhuman/autocomplete/mod.rs @@ -1,15 +1,52 @@ +//! Inline autocomplete domain — facade for the `desktop-automation` gate (#5049). +//! +//! The real engine/history/ops/schemas are `#[cfg(feature = "desktop-automation")]`; +//! the inert `types` module stays compiled in both directions (carve-out), and +//! `stub` re-exposes the always-on caller surface (`all_autocomplete_*`, +//! `global_engine`, `start_if_enabled`) when the feature is off. + +// Inert request/response + status types (dep-free serde). Kept ungated and at +// the domain root so `autocomplete::AutocompleteStatus` (consumed by the +// always-compiled `app_state`) stays available when `desktop-automation` is +// off — the type carve-out from AGENTS.md. +pub mod types; + +// Re-export the carved types at the domain root (ungated) so callers keep the +// historical `autocomplete::{AutocompleteStatus, …}` paths in both builds. This +// is the single re-export of these names; `core` reaches `super::types` directly. +pub use types::{ + AutocompleteAcceptParams, AutocompleteAcceptResult, AutocompleteCurrentParams, + AutocompleteCurrentResult, AutocompleteDebugFocusResult, AutocompleteSetStyleParams, + AutocompleteSetStyleResult, AutocompleteStartParams, AutocompleteStartResult, + AutocompleteStatus, AutocompleteStopParams, AutocompleteStopResult, AutocompleteSuggestion, +}; + +#[cfg(feature = "desktop-automation")] mod core; +#[cfg(feature = "desktop-automation")] pub mod history; +#[cfg(feature = "desktop-automation")] pub mod ops; +#[cfg(feature = "desktop-automation")] mod schemas; +#[cfg(not(feature = "desktop-automation"))] +mod stub; +#[cfg(not(feature = "desktop-automation"))] +pub use stub::*; + +#[cfg(feature = "desktop-automation")] pub use core::*; +#[cfg(feature = "desktop-automation")] pub use history::{ clear_history, list_history, load_recent_examples, query_relevant_examples, save_accepted_completion, save_completion_to_local_docs, AcceptedCompletion, }; +#[cfg(feature = "desktop-automation")] pub use ops as rpc; +#[cfg(feature = "desktop-automation")] pub use ops::*; +#[cfg(feature = "desktop-automation")] pub use schemas::{ all_controller_schemas as all_autocomplete_controller_schemas, all_registered_controllers as all_autocomplete_registered_controllers, diff --git a/src/openhuman/autocomplete/stub.rs b/src/openhuman/autocomplete/stub.rs new file mode 100644 index 0000000000..02606dd8a3 --- /dev/null +++ b/src/openhuman/autocomplete/stub.rs @@ -0,0 +1,79 @@ +//! Disabled-build stub for the `autocomplete` domain (`desktop-automation` off). +//! +//! Re-exposes the always-on caller surface with empty / no-op / disabled bodies. +//! The status/param/result types are carved out in `super::types` and stay +//! compiled in both builds, so `app_state`'s literal `AutocompleteStatus` +//! construction needs no stub. Only behaviour lives here. + +use std::sync::Arc; + +use once_cell::sync::Lazy; + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; +use crate::openhuman::config::Config; + +use super::types::{AutocompleteStatus, AutocompleteStopParams, AutocompleteStopResult}; + +/// Real: `schemas::all_registered_controllers` (re-exported as +/// `all_autocomplete_registered_controllers`). Registration site wants absence: +/// an empty vec leaves `autocomplete.*` unregistered. +pub fn all_autocomplete_registered_controllers() -> Vec { + Vec::new() +} + +/// Real: `schemas::all_controller_schemas` (re-exported as +/// `all_autocomplete_controller_schemas`). +pub fn all_autocomplete_controller_schemas() -> Vec { + Vec::new() +} + +/// Real: `engine::start_if_enabled`. No engine to start when disabled. +pub async fn start_if_enabled(_app_config: &Config) {} + +/// Real: `engine::AutocompleteEngine`. An inert handle exposing only the methods +/// reached by always-on callers (`app_state`, `credentials`, the shutdown hook). +pub struct AutocompleteEngine; + +impl AutocompleteEngine { + /// Real: `AutocompleteEngine::status`. Reports a disabled, not-running engine. + pub async fn status(&self) -> AutocompleteStatus { + disabled_status() + } + + /// Real: `AutocompleteEngine::status_with_config`. + pub async fn status_with_config(&self, _config: &Config) -> AutocompleteStatus { + disabled_status() + } + + /// Real: `AutocompleteEngine::stop`. + pub async fn stop(&self, _params: Option) -> AutocompleteStopResult { + AutocompleteStopResult { stopped: false } + } +} + +static AUTOCOMPLETE_ENGINE: Lazy> = + Lazy::new(|| Arc::new(AutocompleteEngine)); + +/// Real: `engine::global_engine`. Returns the inert singleton handle. +pub fn global_engine() -> Arc { + AUTOCOMPLETE_ENGINE.clone() +} + +fn disabled_status() -> AutocompleteStatus { + AutocompleteStatus { + platform_supported: false, + enabled: false, + running: false, + phase: "disabled".to_string(), + debounce_ms: 0, + model_id: String::new(), + app_name: None, + last_error: Some( + "autocomplete is disabled in this build (rebuild with --features desktop-automation)" + .to_string(), + ), + updated_at_ms: None, + suggestion: None, + } +} diff --git a/src/openhuman/autocomplete/core/types.rs b/src/openhuman/autocomplete/types.rs similarity index 88% rename from src/openhuman/autocomplete/core/types.rs rename to src/openhuman/autocomplete/types.rs index 3c3aee9615..a5e41f61e1 100644 --- a/src/openhuman/autocomplete/core/types.rs +++ b/src/openhuman/autocomplete/types.rs @@ -1,9 +1,14 @@ use crate::openhuman::config::AutocompleteConfig; use serde::{Deserialize, Serialize}; -// Re-export platform types from the accessibility middleware. +// Re-export platform types from the accessibility middleware. Consumed only by +// the gated `core` engine, so gate the re-export + the const in lockstep to keep +// the disabled build warning-clean (this file itself stays compiled for the +// carved-out serde types below). +#[cfg(feature = "desktop-automation")] pub(crate) use crate::openhuman::accessibility::FocusedTextContext; +#[cfg(feature = "desktop-automation")] pub(crate) const MAX_SUGGESTION_CHARS: usize = 64; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/openhuman/desktop_companion/mod.rs b/src/openhuman/desktop_companion/mod.rs index 821c96070b..2e7cb06f77 100644 --- a/src/openhuman/desktop_companion/mod.rs +++ b/src/openhuman/desktop_companion/mod.rs @@ -12,15 +12,37 @@ //! //! This module is export-focused. Operational code lives in `session.rs`, //! `pipeline.rs`, and `pointing.rs`. +//! +//! Facade for the `desktop-automation` gate (#5049): the behavioural submodules +//! (`handoff`, `pipeline`, `pointing`, `schemas`, `session`) are +//! `#[cfg(feature = "desktop-automation")]`. `types` and `bus` stay compiled in +//! both directions — both are dependency-free (serde / tokio broadcast only), and +//! the always-on `core::socketio` subscribes to `bus::subscribe_state_changed()`. +//! When off, nobody publishes, so the broadcast channel simply never fires. Only +//! the controller aggregators are stubbed. +// Dependency-free, compiled in BOTH builds: the inert types and the state-change +// broadcast bus (the always-on `core::socketio` subscribes to it). pub mod bus; +pub mod types; + +#[cfg(feature = "desktop-automation")] pub mod handoff; +#[cfg(feature = "desktop-automation")] pub mod pipeline; +#[cfg(feature = "desktop-automation")] pub mod pointing; +#[cfg(feature = "desktop-automation")] pub mod schemas; +#[cfg(feature = "desktop-automation")] pub mod session; -pub mod types; +#[cfg(not(feature = "desktop-automation"))] +mod stub; +#[cfg(not(feature = "desktop-automation"))] +pub use stub::*; + +#[cfg(feature = "desktop-automation")] pub use schemas::{ all_desktop_companion_controller_schemas, all_desktop_companion_registered_controllers, }; diff --git a/src/openhuman/desktop_companion/stub.rs b/src/openhuman/desktop_companion/stub.rs new file mode 100644 index 0000000000..4ad6bc7df4 --- /dev/null +++ b/src/openhuman/desktop_companion/stub.rs @@ -0,0 +1,20 @@ +//! Disabled-build stub for the `desktop_companion` domain (`desktop-automation` +//! off). Supplies only the controller aggregators — the registration site in +//! `core::all` wants absence, so both return empty vecs. `types` and `bus` stay +//! compiled (dep-free), so the always-on `core::socketio` subscriber and any +//! type consumer need no stub. + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; + +/// Real: `schemas::all_desktop_companion_registered_controllers`. Empty ⇒ the +/// `companion.*` controllers are unregistered (unknown-method over `/rpc`, +/// absent from `/schema`). +pub fn all_desktop_companion_registered_controllers() -> Vec { + Vec::new() +} + +/// Real: `schemas::all_desktop_companion_controller_schemas`. +pub fn all_desktop_companion_controller_schemas() -> Vec { + Vec::new() +} diff --git a/src/openhuman/screen_intelligence/mod.rs b/src/openhuman/screen_intelligence/mod.rs index 5fabf0ae3b..8db4cb8e1f 100644 --- a/src/openhuman/screen_intelligence/mod.rs +++ b/src/openhuman/screen_intelligence/mod.rs @@ -1,32 +1,71 @@ //! Screen capture, accessibility automation, and vision summaries (macOS-focused). +//! +//! Facade for the `desktop-automation` gate (#5049): the capture/vision/engine/ +//! server/cli/tools submodules are `#[cfg(feature = "desktop-automation")]`; the +//! inert `types` module (`AccessibilityStatus`, `CaptureImageRefResult`, the +//! session/permission structs) stays compiled in both directions (carve-out), and +//! `stub` re-exposes the always-on caller surface (`all_screen_intelligence_*`, +//! `global_engine`, `server::{start_if_enabled, try_global_server}`, +//! `rpc::accessibility_capture_image_ref`, `cli`) when the feature is off. +// Inert serde types — compiled in BOTH builds (type carve-out). Consumed by the +// always-on `app_state` (literal `AccessibilityStatus`) and `tools::local_cli` +// (`CaptureImageRefResult`). Re-exports the carved `accessibility` types. +mod types; + +#[cfg(feature = "desktop-automation")] pub(crate) mod cli; +#[cfg(feature = "desktop-automation")] pub mod ops; +#[cfg(feature = "desktop-automation")] mod schemas; +#[cfg(feature = "desktop-automation")] pub mod server; +#[cfg(feature = "desktop-automation")] pub mod tools; +#[cfg(feature = "desktop-automation")] mod capture; +#[cfg(feature = "desktop-automation")] mod capture_worker; +#[cfg(feature = "desktop-automation")] mod engine; +#[cfg(feature = "desktop-automation")] mod helpers; +#[cfg(feature = "desktop-automation")] mod image_processing; +#[cfg(feature = "desktop-automation")] mod input; +#[cfg(feature = "desktop-automation")] mod limits; +#[cfg(feature = "desktop-automation")] mod permissions; +#[cfg(feature = "desktop-automation")] mod processing_worker; +#[cfg(feature = "desktop-automation")] mod state; -mod types; +#[cfg(feature = "desktop-automation")] mod vision; +#[cfg(not(feature = "desktop-automation"))] +mod stub; +#[cfg(not(feature = "desktop-automation"))] +pub use stub::*; + +#[cfg(feature = "desktop-automation")] pub use ops as rpc; +#[cfg(feature = "desktop-automation")] pub use ops::*; +#[cfg(feature = "desktop-automation")] pub use schemas::{ all_controller_schemas as all_screen_intelligence_controller_schemas, all_registered_controllers as all_screen_intelligence_registered_controllers, }; +#[cfg(feature = "desktop-automation")] pub use state::{global_engine, AccessibilityEngine}; + +// Carved types — compiled in BOTH builds. pub use types::*; -#[cfg(test)] +#[cfg(all(test, feature = "desktop-automation"))] mod tests; diff --git a/src/openhuman/screen_intelligence/stub.rs b/src/openhuman/screen_intelligence/stub.rs new file mode 100644 index 0000000000..a197b22faf --- /dev/null +++ b/src/openhuman/screen_intelligence/stub.rs @@ -0,0 +1,178 @@ +//! Disabled-build stub for the `screen_intelligence` domain (`desktop-automation` +//! off). Re-exposes the always-on caller surface — the controller/tool +//! aggregators, the `global_engine()` handle (`apply_config` / `status` / +//! `disable`), the `server` lifecycle, the `rpc` capture entry point, and the +//! `cli` subcommand — with empty / no-op / disabled bodies. +//! +//! The status/session/result types are carved out in `super::types` and stay +//! compiled in both builds, so `app_state`'s literal `AccessibilityStatus` +//! construction needs no stub. Only behaviour lives here. + +use std::sync::Arc; + +use once_cell::sync::Lazy; + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; +use crate::openhuman::config::ScreenIntelligenceConfig; + +use super::types::{ + AccessibilityFeatures, AccessibilityStatus, PermissionState, PermissionStatus, SessionStatus, +}; + +const DISABLED: &str = + "screen intelligence is disabled in this build (rebuild with --features desktop-automation)"; + +/// Real: `schemas::all_registered_controllers` (re-exported as +/// `all_screen_intelligence_registered_controllers`). Empty ⇒ unregistered. +pub fn all_screen_intelligence_registered_controllers() -> Vec { + Vec::new() +} + +/// Real: `schemas::all_controller_schemas`. +pub fn all_screen_intelligence_controller_schemas() -> Vec { + Vec::new() +} + +/// Real: `state::AccessibilityEngine`. Inert handle exposing only the methods the +/// always-on callers (`app_state`, `config::ops::ui`) reach. +pub struct AccessibilityEngine; + +impl AccessibilityEngine { + /// Real: `AccessibilityEngine::apply_config` → `Result`. + pub async fn apply_config( + &self, + config: ScreenIntelligenceConfig, + ) -> Result { + Ok(disabled_status(config)) + } + + /// Real: `AccessibilityEngine::status`. + pub async fn status(&self) -> AccessibilityStatus { + disabled_status(ScreenIntelligenceConfig::default()) + } + + /// Real: `AccessibilityEngine::disable` → `SessionStatus`. + pub async fn disable(&self, reason: Option) -> SessionStatus { + disabled_session(reason) + } +} + +static ACCESSIBILITY_ENGINE: Lazy> = + Lazy::new(|| Arc::new(AccessibilityEngine)); + +/// Real: `state::global_engine`. +pub fn global_engine() -> Arc { + ACCESSIBILITY_ENGINE.clone() +} + +/// Disabled-build mirror of `screen_intelligence::ops` — the `rpc` alias. +/// `tools::local_cli` reaches `rpc::accessibility_capture_image_ref`. +pub mod rpc { + use super::DISABLED; + use crate::openhuman::screen_intelligence::types::CaptureImageRefResult; + use crate::rpc::RpcOutcome; + + /// Real: `ops::accessibility_capture_image_ref`. + pub async fn accessibility_capture_image_ref( + ) -> Result, String> { + log::debug!( + "[screen_intelligence] capture_image_ref rejected: desktop-automation disabled at compile time" + ); + Ok(RpcOutcome::new( + CaptureImageRefResult { + ok: false, + image_ref: None, + mime_type: "image/png".to_string(), + bytes_estimate: None, + message: DISABLED.to_string(), + }, + vec![DISABLED.to_string()], + )) + } +} + +/// Disabled-build mirror of `screen_intelligence::server`. +pub mod server { + use crate::openhuman::config::Config; + + /// Real: `server::start_if_enabled`. No server to start when disabled. + pub async fn start_if_enabled(_app_config: &Config) {} + + /// Real: `server::try_global_server`. Never a running server when disabled; + /// returning `None` short-circuits the `if let Some(server)` stop path in + /// `credentials::ops`. + pub fn try_global_server() -> Option> { + None + } + + /// Real: `server::SiServer`. Only reached via `try_global_server()`, which the + /// stub always returns `None` for, so no method body is ever invoked — but the + /// `server.stop()` call site still needs the method to exist to type-check. + pub struct SiServer; + + impl SiServer { + pub async fn stop(&self) {} + } +} + +/// Disabled-build mirror of `screen_intelligence::cli`. +pub mod cli { + use anyhow::Result; + + /// Real: `cli::run_screen_intelligence_command`. Reports the build fact rather + /// than running a no-op command. + pub(crate) fn run_screen_intelligence_command(_args: &[String]) -> Result<()> { + log::debug!( + "[screen_intelligence] CLI command rejected: desktop-automation disabled at compile time" + ); + Err(anyhow::anyhow!(super::DISABLED)) + } +} + +fn disabled_status(config: ScreenIntelligenceConfig) -> AccessibilityStatus { + AccessibilityStatus { + platform_supported: false, + permissions: PermissionStatus { + screen_recording: PermissionState::Unknown, + accessibility: PermissionState::Unknown, + input_monitoring: PermissionState::Unknown, + microphone: PermissionState::Unknown, + }, + features: AccessibilityFeatures { + screen_monitoring: false, + }, + session: disabled_session(None), + foreground_context: None, + config, + denylist: Vec::new(), + is_context_blocked: false, + permission_check_process_path: None, + core_process: None, + } +} + +fn disabled_session(reason: Option) -> SessionStatus { + SessionStatus { + active: false, + started_at_ms: None, + expires_at_ms: None, + remaining_ms: None, + ttl_secs: 0, + panic_hotkey: String::new(), + stop_reason: reason, + capture_count: 0, + frames_in_memory: 0, + last_capture_at_ms: None, + last_context: None, + last_window_title: None, + vision_enabled: false, + vision_state: "disabled".to_string(), + vision_queue_depth: 0, + last_vision_at_ms: None, + last_vision_summary: None, + vision_persist_count: 0, + last_vision_persisted_key: None, + last_vision_persist_error: None, + } +} diff --git a/src/openhuman/tools/impl/mod.rs b/src/openhuman/tools/impl/mod.rs index 0dbd542fc1..fa92ff96c5 100644 --- a/src/openhuman/tools/impl/mod.rs +++ b/src/openhuman/tools/impl/mod.rs @@ -1,4 +1,9 @@ pub mod browser; +// The `computer` agent-tool family (ax_interact / automate / mouse / keyboard) is +// compiled out with the `desktop-automation` feature (#5049). Leaf gate: the tool +// registrations in `tools/ops.rs` carry matching `#[cfg]` so the tools are absent +// (not error-degraded) when off. +#[cfg(feature = "desktop-automation")] pub mod computer; pub mod document; pub mod filesystem; @@ -7,6 +12,7 @@ pub mod presentation; pub mod system; pub use browser::*; +#[cfg(feature = "desktop-automation")] pub use computer::*; pub use document::DocumentTool; pub use filesystem::*; diff --git a/src/openhuman/tools/impl/system/mod.rs b/src/openhuman/tools/impl/system/mod.rs index 1610fc81b0..5435dc010e 100644 --- a/src/openhuman/tools/impl/system/mod.rs +++ b/src/openhuman/tools/impl/system/mod.rs @@ -26,7 +26,10 @@ pub use detect_tools::DetectToolsTool; pub use insert_sql_record::InsertSqlRecordTool; pub use install_tool::InstallToolTool; pub use launch_app::LaunchAppTool; -// Reused by the `automate` inner loop to launch an app mid-flow. +// Reused by the `automate` inner loop (`desktop-automation`) and the always-on +// voice command router (`voice`) to launch an app mid-flow. Gated to the union of +// its consumers so the fully-slim build (both off) stays warning-clean. +#[cfg(any(feature = "desktop-automation", feature = "voice"))] pub(crate) use launch_app::launch_platform; pub use lsp::{lsp_capability_enabled, LspTool, LSP_ENABLED_ENV}; pub use node_exec::NodeExecTool; diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 9326370ef5..7e97814c3c 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -47,6 +47,7 @@ pub use crate::openhuman::people::tools::*; pub use crate::openhuman::referral::tools::*; #[cfg(feature = "flows")] pub use crate::openhuman::rhai_workflows::tools::*; +#[cfg(feature = "desktop-automation")] pub use crate::openhuman::screen_intelligence::tools::*; pub use crate::openhuman::search::tools::*; pub use crate::openhuman::security::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index a92b314c80..1b9f89430c 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -246,12 +246,17 @@ pub fn all_tools_with_runtime( // value ready to paste into a tool argument. Box::new(ResolveTimeTool::new()), Box::new(LaunchAppTool::new()), + // `ax_interact` + `automate` are the `computer`-family tools — compiled + // out with the `desktop-automation` feature (same idiom as the + // `screen_intelligence_*` block below). + #[cfg(feature = "desktop-automation")] Box::new(AxInteractTool::new( root_config.computer_control.ax_interact_mutations, )), // Multi-step UI automation in one call. Shares the ax_interact opt-in // (mutations) and sensitive-app denylist; runs a Rust perceive→act→verify // loop with a fast model so the chat model stays out of the click loop. + #[cfg(feature = "desktop-automation")] Box::new(AutomateTool::new( root_config.computer_control.ax_interact_mutations, )), @@ -685,20 +690,39 @@ pub fn all_tools_with_runtime( // call tools default-ON; OS permission prompts (screen_permissions), // MCP install/uninstall (mcp_manage), and persona/workspace writers // (workspace_manage) ship default-OFF via `tools::user_filter`. + // + // The 15 `screen_intelligence_*` tools are compiled out with the + // `desktop-automation` feature — the per-element attrs inside the + // `vec![]` mirror the `mcp` idiom below. + #[cfg(feature = "desktop-automation")] Box::new(ScreenStatusTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenCaptureImageRefTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenVisionRecentTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenVisionFlushTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenRefreshPermissionsTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenCaptureNowTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenCaptureTestTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenSessionStartTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenSessionStopTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenInputActionTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenGlobeStartTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenGlobePollTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenGlobeStopTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenRequestPermissionsTool), + #[cfg(feature = "desktop-automation")] Box::new(ScreenRequestPermissionTool), // MCP registry (dynamic, user-installed servers) — compiled out with // the `mcp` feature. Per-element attrs inside the `vec![]` mirror the @@ -1005,7 +1029,10 @@ pub fn all_tools_with_runtime( tools.push(Box::new(ScreenshotTool::new(security.clone()))); tools.push(Box::new(ImageInfoTool::new(security.clone()))); - // Native mouse + keyboard control (disabled by default) + // Native mouse + keyboard control (disabled by default). The `MouseTool` / + // `KeyboardTool` are `computer`-family tools — compiled out with the + // `desktop-automation` feature. + #[cfg(feature = "desktop-automation")] if root_config.computer_control.enabled { tools.push(Box::new(MouseTool::new(security.clone()))); tools.push(Box::new(KeyboardTool::new(security.clone()))); @@ -1353,7 +1380,17 @@ fn tool_group(name: &str) -> crate::core::all::DomainGroup { if name.starts_with("thread_") || name.starts_with("todo_") || THREADS_EXTRA.contains(&name) { return DomainGroup::Threads; } - // Everything else — shell/file/screen/config/security/agent/billing/… — is + // Desktop-automation family: the 15 `screen_intelligence_*` tools plus the + // four `computer`-family tools (`ax_interact`, `automate`, `mouse`, + // `keyboard`). Compiled out with the `desktop-automation` feature; tagged so + // the runtime `DomainSet::desktop_automation` axis gates them consistently + // with the autocomplete/screen_intelligence/desktop_companion controllers. + if name.starts_with("screen_intelligence_") + || matches!(name, "ax_interact" | "automate" | "mouse" | "keyboard") + { + return DomainGroup::DesktopAutomation; + } + // Everything else — shell/file/config/security/agent/billing/… — is // Platform: present under full(), absent under harness()/none(). DomainGroup::Platform } diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index bd1b0d7ae2..414830009c 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -972,6 +972,9 @@ fn all_tools_excludes_computer_control_when_disabled() { ); } +// The `mouse` / `keyboard` computer-control tools are gated behind +// `desktop-automation` (#5049), so this test only applies when the feature is on. +#[cfg(feature = "desktop-automation")] #[test] fn all_tools_includes_computer_control_when_enabled() { let tmp = TempDir::new().unwrap(); @@ -1617,10 +1620,15 @@ async fn readonly_acting_tools_carry_policy_blocked_marker() { Box::new(CsvExportTool::new(sec.clone())), serde_json::json!({ "data": "col1\nval1", "filename": "x.csv" }), ), + // The `computer`-family tools are compiled out with the + // `desktop-automation` feature; gate these two cases per-element so the + // rest of the read-only policy assertions still run in the slim build. + #[cfg(feature = "desktop-automation")] ( Box::new(KeyboardTool::new(sec.clone())), serde_json::json!({}), ), + #[cfg(feature = "desktop-automation")] (Box::new(MouseTool::new(sec.clone())), serde_json::json!({})), ( Box::new(BrowserOpenTool::new(sec.clone(), vec![])), @@ -2160,20 +2168,39 @@ fn money_default_off_tools_retained_when_opted_in() { // ── Theme: Desktop perception, MCP registry, workspace ────────────────────── const DESKTOP_TOOLS: &[&str] = &[ + // The 15 `screen_intelligence_*` tools are compiled out with the + // `desktop-automation` feature, so these expectations are gated per-element + // (same idiom as the `mcp_registry_*` block below) rather than gating the + // `desktop_tools_are_registered` test away wholesale. + #[cfg(feature = "desktop-automation")] "screen_intelligence_status", + #[cfg(feature = "desktop-automation")] "screen_intelligence_capture_image_ref", + #[cfg(feature = "desktop-automation")] "screen_intelligence_vision_recent", + #[cfg(feature = "desktop-automation")] "screen_intelligence_vision_flush", + #[cfg(feature = "desktop-automation")] "screen_intelligence_refresh_permissions", + #[cfg(feature = "desktop-automation")] "screen_intelligence_capture_now", + #[cfg(feature = "desktop-automation")] "screen_intelligence_capture_test", + #[cfg(feature = "desktop-automation")] "screen_intelligence_session_start", + #[cfg(feature = "desktop-automation")] "screen_intelligence_session_stop", + #[cfg(feature = "desktop-automation")] "screen_intelligence_input_action", + #[cfg(feature = "desktop-automation")] "screen_intelligence_globe_listener_start", + #[cfg(feature = "desktop-automation")] "screen_intelligence_globe_listener_poll", + #[cfg(feature = "desktop-automation")] "screen_intelligence_globe_listener_stop", + #[cfg(feature = "desktop-automation")] "screen_intelligence_request_permissions", + #[cfg(feature = "desktop-automation")] "screen_intelligence_request_permission", // The `mcp_registry_*` desktop surface is compiled out with the `mcp` // feature, so these expectations are gated per-element rather than gating @@ -2206,7 +2233,9 @@ const DESKTOP_TOOLS: &[&str] = &[ ]; const DESKTOP_DEFAULT_OFF: &[&str] = &[ + #[cfg(feature = "desktop-automation")] "screen_intelligence_request_permissions", + #[cfg(feature = "desktop-automation")] "screen_intelligence_request_permission", #[cfg(feature = "mcp")] "mcp_registry_install", @@ -2218,7 +2247,9 @@ const DESKTOP_DEFAULT_OFF: &[&str] = &[ ]; const DESKTOP_ALWAYS_ON: &[&str] = &[ + #[cfg(feature = "desktop-automation")] "screen_intelligence_status", + #[cfg(feature = "desktop-automation")] "screen_intelligence_capture_now", #[cfg(feature = "mcp")] "mcp_registry_search", @@ -2236,6 +2267,30 @@ fn desktop_tools_are_registered() { assert_contains_all(&names, DESKTOP_TOOLS); } +/// Negative half of the `desktop-automation` gate (#5049): with the cluster +/// compiled out, no `screen_intelligence_*` tool and none of the `computer` +/// family (`ax_interact` / `automate` / `mouse` / `keyboard`) may be advertised — +/// they must be *absent*, not degraded to a runtime error. Pairs with +/// `desktop_tools_are_registered` above. +#[cfg(not(feature = "desktop-automation"))] +#[test] +fn screen_intelligence_tools_absent_when_feature_off() { + let tmp = TempDir::new().unwrap(); + let names = tool_names(&expansion_tools_for(&tmp)); + assert!( + !names.iter().any(|n| n.starts_with("screen_intelligence_")), + "no `screen_intelligence_*` tool may be advertised when \ + `desktop-automation` is off; got: {names:?}" + ); + for computer_tool in ["ax_interact", "automate", "mouse", "keyboard"] { + assert!( + !names.iter().any(|n| n == computer_tool), + "`computer` tool `{computer_tool}` must be absent when \ + `desktop-automation` is off; got: {names:?}" + ); + } +} + #[test] fn desktop_default_off_tools_are_filtered_when_not_opted_in() { let tmp = TempDir::new().unwrap(); From fc0222f3b6378671f91f94add1156ca64a9b2bfb Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:33:39 +0530 Subject: [PATCH 04/72] perf(agent): share one Arc across per-build tool/provider/reflection (#5050) (#5062) --- .../harness/session/builder/builder_tests.rs | 68 ++++++++++++++++++ .../agent/harness/session/builder/factory.rs | 72 +++++++++++++------ 2 files changed, 118 insertions(+), 22 deletions(-) diff --git a/src/openhuman/agent/harness/session/builder/builder_tests.rs b/src/openhuman/agent/harness/session/builder/builder_tests.rs index 06bbd26cc6..7e44eebcda 100644 --- a/src/openhuman/agent/harness/session/builder/builder_tests.rs +++ b/src/openhuman/agent/harness/session/builder/builder_tests.rs @@ -240,3 +240,71 @@ async fn build_session_agent_falls_back_to_global_default_when_no_definition() { "with no definition, the global config default must be used unchanged" ); } + +// ── #5050 Fix 1: shared `Arc` for the per-build tool config ────────── + +#[test] +fn tool_config_shares_base_arc_when_ui_control_toggle_off() { + use super::factory::resolve_tool_config; + use std::sync::Arc; + + let tmp = tempfile::TempDir::new().unwrap(); + let mut cfg = test_config(&tmp); + cfg.computer_control.ax_interact_mutations = false; + let base = Arc::new(cfg); + + // No enabled tools → the App-UI-Control toggle does not fire → the tool + // registry shares the base `Arc` (a refcount bump), not a deep clone. + let resolved = resolve_tool_config(&base, &[]); + assert!( + Arc::ptr_eq(&base, &resolved), + "toggle off must reuse the base config Arc rather than deep-clone it" + ); +} + +#[test] +fn tool_config_grant_is_scoped_and_leaves_base_untouched() { + use super::factory::resolve_tool_config; + use std::sync::Arc; + + let tmp = tempfile::TempDir::new().unwrap(); + let mut cfg = test_config(&tmp); + cfg.computer_control.ax_interact_mutations = false; + let base = Arc::new(cfg); + + // Enabling `ax_interact` fires the toggle: the tool registry gets the mutation + // grant, but as a *distinct* instance — the base config (which feeds the turn + // provider + reflection hook) must stay ungranted so the grant cannot leak. + let resolved = resolve_tool_config(&base, &["ax_interact".to_string()]); + assert!( + resolved.computer_control.ax_interact_mutations, + "the tool-registry config must carry the granted mutation flag" + ); + assert!( + !Arc::ptr_eq(&base, &resolved), + "granting must produce a distinct config, not alias the shared base" + ); + assert!( + !base.computer_control.ax_interact_mutations, + "the base config must stay ungranted — the grant is scoped to the tool registry" + ); +} + +#[test] +fn tool_config_reuses_base_when_mutations_already_granted_globally() { + use super::factory::resolve_tool_config; + use std::sync::Arc; + + let tmp = tempfile::TempDir::new().unwrap(); + let mut cfg = test_config(&tmp); + cfg.computer_control.ax_interact_mutations = true; + let base = Arc::new(cfg); + + // Already granted globally (e.g. Full autonomy) → no clone even when the tool + // is enabled, since there is nothing to grant. + let resolved = resolve_tool_config(&base, &["ax_interact".to_string()]); + assert!( + Arc::ptr_eq(&base, &resolved), + "an already-granted base config must not be re-cloned" + ); +} diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 2c02861a2a..a711f138b9 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -391,23 +391,17 @@ impl Agent { // (#3762). The actions stay approval-gated and bound by the // sensitive-app denylist; Full autonomy continues to grant this // independently via `app_control_enabled`. - let adjusted_config: Config; - let tool_config: &Config = if !config.computer_control.ax_interact_mutations - && tools::enables_app_ui_control_mutations(&enabled_tools) - { - let mut c = config.clone(); - c.computer_control.ax_interact_mutations = true; - log::debug!( - "[session-builder] action=grant_app_ui_control_mutations source=features_toggle" - ); - adjusted_config = c; - &adjusted_config - } else { - config - }; + // Share a single `Arc` across the heavyweight per-build consumers + // (the tool registry, the reflection hook, the turn provider) instead of + // deep-cloning the large `Config` at each site (#5050, Fix 1). `Config` is + // immutable after construction, so one refcounted instance is behaviourally + // identical to N independent clones. `resolve_tool_config` handles the one + // consumer that needs a *different* config — the App-UI-Control toggle. + let base_config: Arc = Arc::new(config.clone()); + let tool_config: Arc = resolve_tool_config(&base_config, &enabled_tools); let mut tools = tools::all_tools_with_runtime( - Arc::new(tool_config.clone()), + Arc::clone(&tool_config), &security, runtime, audit, @@ -416,7 +410,7 @@ impl Agent { &tool_config.http_request, &tool_config.action_dir, &tool_config.agents, - tool_config, + &tool_config, profile_skill_allowlist.as_ref(), profile_mcp_allowlist.as_deref(), ); @@ -694,11 +688,10 @@ impl Agent { Vec::new(); if config.learning.enabled { if config.learning.reflection_enabled { - // Only the reflection hook needs an owned snapshot of the - // full config, so create the `Arc` lazily inside this - // branch instead of paying for the clone whenever - // `learning.enabled` is true. - let full_config = Arc::new(config.clone()); + // The reflection hook needs an owned `Arc`; reuse the + // shared base config (a refcount bump) rather than a second deep + // clone of the full config (#5050, Fix 1). + let full_config = Arc::clone(&base_config); // For cloud reflection, wrap the provider in an Arc. // For local, no provider needed. let reflection_provider: Option< @@ -1164,7 +1157,7 @@ impl Agent { effective_agent_config.max_tool_iterations = def_cap; } let mut builder = Agent::builder() - .crate_native_provider(provider_role, std::sync::Arc::new(config.clone())) + .crate_native_provider(provider_role, Arc::clone(&base_config)) .tools(tools) .visible_tool_names(visible) .memory(memory) @@ -1221,6 +1214,41 @@ impl Agent { } } +/// Resolve the `Config` the tool registry is built from (#5050, Fix 1). +/// +/// Normally this is the shared `base_config` — returned as a refcount bump, not a +/// deep clone. The one exception is the App-UI-Control / App-Automation features +/// toggle (#3762): when the user enabled the `ax_interact` / `automate` tools in +/// Settings without global Full autonomy, the tool registry (and *only* the tool +/// registry) receives a copy of the config with `ax_interact_mutations` granted. +/// Scoping the grant here keeps the turn provider and reflection hook on the +/// ungranted base config, and clones at most once — only when the toggle fires. +pub(super) fn resolve_tool_config( + base_config: &Arc, + enabled_tools: &[String], +) -> Arc { + log::trace!( + "[session-builder] action=resolve_tool_config phase=enter enabled_tools_count={} base_ax_interact_mutations={}", + enabled_tools.len(), + base_config.computer_control.ax_interact_mutations + ); + if !base_config.computer_control.ax_interact_mutations + && tools::enables_app_ui_control_mutations(enabled_tools) + { + let mut granted = (**base_config).clone(); + granted.computer_control.ax_interact_mutations = true; + log::debug!( + "[session-builder] action=resolve_tool_config phase=exit outcome=granted_app_ui_control_mutations source=features_toggle" + ); + Arc::new(granted) + } else { + log::debug!( + "[session-builder] action=resolve_tool_config phase=exit outcome=reused_base_config" + ); + Arc::clone(base_config) + } +} + fn definition_disallows_tool(disallowed: &[String], name: &str) -> bool { disallowed.iter().any(|entry| { if let Some(prefix) = entry.strip_suffix('*') { From 846db8aadc15701c44fcc4ae718c1ee4df597789 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:39:35 +0300 Subject: [PATCH 05/72] feat(flows): render Workflow Copilot chat via the shared composer transcript (ChatThreadView) (#5097) --- .../flows/ToolActivityChip.test.tsx | 67 - app/src/components/flows/ToolActivityChip.tsx | 41 - .../flows/WorkflowCopilotPanel.test.tsx | 301 +---- .../components/flows/WorkflowCopilotPanel.tsx | 183 +-- .../features/conversations/Conversations.tsx | 991 +-------------- .../components/ChatThreadView.test.tsx | 233 ++++ .../components/ChatThreadView.tsx | 1131 +++++++++++++++++ .../toolCallEnvelope.test.ts} | 2 +- .../toolCallEnvelope.ts} | 11 +- 9 files changed, 1478 insertions(+), 1482 deletions(-) delete mode 100644 app/src/components/flows/ToolActivityChip.test.tsx delete mode 100644 app/src/components/flows/ToolActivityChip.tsx create mode 100644 app/src/features/conversations/components/ChatThreadView.test.tsx create mode 100644 app/src/features/conversations/components/ChatThreadView.tsx rename app/src/lib/{flows/copilotMessageSanitizer.test.ts => chat/toolCallEnvelope.test.ts} (98%) rename app/src/lib/{flows/copilotMessageSanitizer.ts => chat/toolCallEnvelope.ts} (91%) diff --git a/app/src/components/flows/ToolActivityChip.test.tsx b/app/src/components/flows/ToolActivityChip.test.tsx deleted file mode 100644 index d77a1be9df..0000000000 --- a/app/src/components/flows/ToolActivityChip.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import ToolActivityChip from './ToolActivityChip'; - -vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); - -describe('ToolActivityChip', () => { - it('renders the proposing label for propose_workflow', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('renders the proposing label for revise_workflow too', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('renders the dry-running label for dry_run_workflow', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.dryRunning' - ); - }); - - it('renders the saving label for save_workflow', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent('flows.copilot.tool.saving'); - }); - - it('renders a generic "using tools" label for an unrecognized tool name', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.usingTools' - ); - }); - - it('renders nothing for an empty toolNames array', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders the shared label when every tool name maps to the same label', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('renders the generic label when tool names map to different labels', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.usingTools' - ); - }); - - it('renders the generic label when one tool is unrecognized, even if another is recognized', () => { - render(); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.usingTools' - ); - }); -}); diff --git a/app/src/components/flows/ToolActivityChip.tsx b/app/src/components/flows/ToolActivityChip.tsx deleted file mode 100644 index 0d39c1ea61..0000000000 --- a/app/src/components/flows/ToolActivityChip.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * ToolActivityChip — replaces raw tool-call JSON in the copilot chat with a - * compact, human-readable status pill (B25). Users should know the agent - * used a tool (it explains why the turn took longer), but they should never - * see the raw JSON arguments (e.g. the whole workflow graph payload). - */ -import { useT } from '../../lib/i18n/I18nContext'; - -interface Props { - /** Tool names extracted from the turn's tool-call envelope, in call order. */ - toolNames: string[]; -} - -/** Tools that map to a specific, more informative status label. */ -const KNOWN_TOOL_LABEL_KEYS: Record = { - propose_workflow: 'flows.copilot.tool.proposing', - revise_workflow: 'flows.copilot.tool.proposing', - dry_run_workflow: 'flows.copilot.tool.dryRunning', - save_workflow: 'flows.copilot.tool.saving', -}; - -export default function ToolActivityChip({ toolNames }: Props) { - const { t } = useT(); - if (toolNames.length === 0) return null; - - // Every tool must map to the SAME recognized label before we show a - // specific status (e.g. all of `propose_workflow`/`revise_workflow` map to - // "proposing..."); any unrecognized tool, or a mix of tools with different - // labels, falls back to a generic "Using tools..." pill rather than - // picking one tool's label arbitrarily or dumping tool names verbatim. - const labelKeys = toolNames.map(name => KNOWN_TOOL_LABEL_KEYS[name]); - const labelKey = labelKeys.every(key => key && key === labelKeys[0]) ? labelKeys[0] : undefined; - - return ( - - {t(labelKey ?? 'flows.copilot.tool.usingTools')} - - ); -} diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index f382f14692..1e896b19b0 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -2,28 +2,30 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { WorkflowGraph, WorkflowNode } from '../../lib/flows/types'; -import type { ToolTimelineEntry, WorkflowProposal } from '../../store/chatRuntimeSlice'; +import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; import WorkflowCopilotPanel from './WorkflowCopilotPanel'; vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); -interface MockMessage { - id: string; - content: string; - sender: 'user' | 'agent'; - extraMetadata?: { isInterim?: boolean }; -} +// The panel now delegates its entire transcript to the shared `ChatThreadView` +// (message bubbles, tool timeline, sub-agent drawer, streaming previews). That +// component reads the real Redux store; its rendering — including the B25 +// tool-call-envelope unwrap and interim-narration handling — is covered by +// `features/conversations/components/ChatThreadView.test.tsx`. Here we stub it +// so these tests stay focused on the copilot's OWN authoring behavior (the +// `flows_build` send path, seed auto-sends, and the proposal / capped cards) +// without needing a Redux Provider. +vi.mock('../../features/conversations/components/ChatThreadView', () => ({ + ChatThreadView: ({ emptyContent }: { emptyContent?: unknown }) => ( +
{emptyContent as never}
+ ), +})); const hookState = vi.hoisted(() => ({ + threadId: null as string | null, sending: false, proposal: null as WorkflowProposal | null, capped: false, - // Panel renders `displayMessages` (already interim-filtered upstream by - // `useWorkflowBuilderChat`) — kept separate from `messages` in these tests - // so a mismatch between the two proves the panel is reading the right field. - displayMessages: [] as MockMessage[], - toolTimeline: [] as ToolTimelineEntry[], - liveResponse: '', error: null as string | null, send: vi.fn(), clearProposal: vi.fn(), @@ -50,12 +52,10 @@ const baseGraph = graph(['a', 'b']); describe('WorkflowCopilotPanel', () => { beforeEach(() => { + hookState.threadId = null; hookState.sending = false; hookState.proposal = null; hookState.capped = false; - hookState.displayMessages = []; - hookState.toolTimeline = []; - hookState.liveResponse = ''; hookState.error = null; hookState.send = vi.fn().mockResolvedValue({ outcome: 'dispatched', proposed: false }); hookState.clearProposal = vi.fn(); @@ -147,268 +147,13 @@ describe('WorkflowCopilotPanel', () => { expect(thirdArg.request.instruction).toBe('also add a filter step'); }); - it('renders the conversation transcript (user + agent turns)', () => { - hookState.displayMessages = [ - { id: 'm1', content: 'add a Slack step', sender: 'user' }, - { id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' }, - ]; - render( - - ); - expect(screen.getByTestId('workflow-copilot-user')).toHaveTextContent('add a Slack step'); - expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent( - 'Done — proposed a Slack notification.' - ); - // With a transcript present, the empty-state hint is gone. - expect(screen.queryByTestId('workflow-copilot-empty')).not.toBeInTheDocument(); - }); - - it('B25: unwraps a raw tool-call envelope message into clean text + a tool activity chip, never raw JSON', () => { - // Repro for B25: a turn that both talks and calls a tool can land in the - // thread transcript as the provider wire-format `{ content, tool_calls }` - // envelope. The panel must render only the human text — never the raw - // JSON — plus a compact status chip for the tool activity. - hookState.displayMessages = [ - { id: 'm1', content: 'build me a Slack digest', sender: 'user' }, - { - id: 'm2', - content: JSON.stringify({ - content: "Here's the workflow I propose.", - tool_calls: [{ id: 'call_1', name: 'propose_workflow', arguments: '{"nodes":[]}' }], - }), - sender: 'agent', - }, - ]; - render( - - ); - const bubble = screen.getByTestId('workflow-copilot-agent'); - expect(bubble).toHaveTextContent("Here's the workflow I propose."); - // The raw envelope must never reach the DOM as text. - expect(bubble).not.toHaveTextContent('tool_calls'); - expect(bubble).not.toHaveTextContent('"nodes":[]'); - expect(screen.getByTestId('tool-activity-chip')).toHaveTextContent( - 'flows.copilot.tool.proposing' - ); - }); - - it('does not render an isInterim agent message as a bubble, only the terminal one', () => { - // The panel only ever renders `displayMessages` — the same filtered set - // `useWorkflowBuilderChat` computes from the raw transcript (isInterim - // agent messages dropped since that narration already streams live via - // the tool timeline / live text). Mirror that filter here so this test - // documents (and would catch a regression in) the composition: an - // isInterim message must never reach the panel as a bubble, while the - // terminal (non-interim) answer still does. - const raw: MockMessage[] = [ - { id: 'm1', content: 'build me a Slack digest', sender: 'user' }, - { - id: 'm2', - content: 'Let me check your calendar first.', - sender: 'agent', - extraMetadata: { isInterim: true }, - }, - { id: 'm3', content: 'Done — proposed a Slack notification.', sender: 'agent' }, - ]; - hookState.displayMessages = raw.filter(m => m.sender === 'user' || !m.extraMetadata?.isInterim); - - render( - - ); - expect(screen.queryByText('Let me check your calendar first.')).not.toBeInTheDocument(); - expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent( - 'Done — proposed a Slack notification.' - ); - }); - - it('renders the shared tool timeline + streaming reply during a builder turn', () => { - hookState.sending = true; - hookState.toolTimeline = [ - { id: 'call-1', name: 'propose_workflow', round: 0, status: 'running' } as ToolTimelineEntry, - ]; - hookState.liveResponse = 'Drafting your workflow…'; - render( - - ); - // The shared ToolTimelineBlock renders (not the bespoke transcript), and the - // one-shot "thinking" placeholder is suppressed once activity is streaming. - expect(screen.getByTestId('workflow-copilot-timeline')).toBeInTheDocument(); - expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument(); - }); - - it('shows the live reply as a bubble before the first tool call streams', () => { - hookState.sending = true; - hookState.toolTimeline = []; - hookState.liveResponse = 'Thinking about your Slack digest…'; - render( - - ); - expect(screen.getByTestId('workflow-copilot-streaming')).toHaveTextContent( - 'Thinking about your Slack digest…' - ); - // No tool timeline yet, and the plain "thinking" line is replaced by the - // streamed text. - expect(screen.queryByTestId('workflow-copilot-timeline')).not.toBeInTheDocument(); - expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument(); - }); - - // Regression coverage for the "copilot chat gets stuck" bug: the panel used - // to force-scroll to the bottom on every render of a streaming turn (an - // unconditional `scrollTo` effect keyed on messages/tool timeline/live - // text), which fought a user trying to scroll up to read. The panel now - // delegates to the shared `useStickToBottom` hook (same one the main chat - // surfaces use) — these tests exercise the REAL hook (not mocked) wired - // through the actual transcript container. - describe('transcript scroll pinning (#regression: chat gets stuck)', () => { - function scrollContainer() { - return screen.getByTestId('workflow-copilot-transcript'); - } - - // jsdom performs no real layout, so scroll metrics are inert unless - // defined explicitly — mirrors the approach in useStickToBottom.test.ts. - function mockScrollMetrics( - el: HTMLElement, - metrics: { scrollTop: number; scrollHeight: number; clientHeight: number } - ) { - Object.defineProperty(el, 'scrollHeight', { - configurable: true, - value: metrics.scrollHeight, - }); - Object.defineProperty(el, 'clientHeight', { - configurable: true, - value: metrics.clientHeight, - }); - Object.defineProperty(el, 'scrollTop', { - configurable: true, - writable: true, - value: metrics.scrollTop, - }); - } - - function renderPanel() { - return render( - - ); - } - - it('keeps the transcript container freely scrollable (overflow-y-auto)', () => { - renderPanel(); - expect(scrollContainer()).toHaveClass('overflow-y-auto'); - }); - - it('auto-scrolls to the bottom when a new message arrives while the user is pinned to the bottom', () => { - hookState.displayMessages = [{ id: 'm1', content: 'hi', sender: 'user' }]; - const { rerender } = renderPanel(); - const container = scrollContainer(); - - mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 }); - rerender( - - ); - - // A new agent turn lands while the user never scrolled away. - hookState.displayMessages = [ - ...hookState.displayMessages, - { id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' }, - ]; - mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 300, clientHeight: 50 }); - rerender( - - ); - - expect(container.scrollTop).toBe(300); - }); - - it('does NOT force-scroll the user back down once they have scrolled up to read history', () => { - hookState.displayMessages = [{ id: 'm1', content: 'hi', sender: 'user' }]; - const { rerender } = renderPanel(); - const container = scrollContainer(); - - mockScrollMetrics(container, { scrollTop: 50, scrollHeight: 100, clientHeight: 50 }); - rerender( - - ); - - // The user scrolls up to read earlier context, well past the stick - // threshold (400 - 0 - 50 = 350px from the bottom). - mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 400, clientHeight: 50 }); - fireEvent.scroll(container); - - // A new agent turn streams in regardless — this is exactly the bug: - // the old unconditional `scrollTo` effect would yank the reader back - // to the bottom here. The container must stay put. - hookState.displayMessages = [ - ...hookState.displayMessages, - { id: 'm2', content: 'Still drafting…', sender: 'agent' }, - ]; - mockScrollMetrics(container, { scrollTop: 0, scrollHeight: 700, clientHeight: 50 }); - rerender( - - ); - - expect(container.scrollTop).toBe(0); - }); - }); + // Transcript rendering (message bubbles, the shared tool timeline + sub-agent + // drawer, streaming previews, the B25 tool-call-envelope unwrap, interim + // narration, and stick-to-bottom scroll pinning) now lives in the shared + // `ChatThreadView` and is covered by + // `features/conversations/components/ChatThreadView.test.tsx`. The panel here + // stubs that component (see the mock above), so these tests assert only the + // copilot's own authoring surface (send path, seeds, proposal / capped cards). it('surfaces a new proposal to the host and shows the added/removed diff', () => { const onProposal = vi.fn(); diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index 657e6bbb6a..af4a67ea36 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -8,13 +8,16 @@ * transcript, surfaces each proposal's node-level diff, and hands Accept/Reject * up to the host, which applies it to the local draft overlay. * - * Chat UI parity: the copilot reuses the SHARED chat surface end-to-end — the - * same {@link ChatComposer} the main chat windows use (mic/attachments off - * here), turns render as bubbles via the shared {@link BubbleMarkdown}, and the - * builder turn's live tool activity + streaming reply render through the shared - * {@link ToolTimelineBlock} (fed from the runtime's `toolTimelineByThread` / - * `streamingAssistantByThread`, streamed here by Phase B). So the copilot reads - * like a real chat rather than a one-shot form. + * Chat UI parity: the copilot renders its transcript through the SAME + * {@link ChatThreadView} the home composer chat uses — message bubbles, + * past-turn insights, the shared tool timeline + sub-agent drawer, and the + * streaming / interrupted / parallel previews — driven by this copilot's + * DEDICATED thread. `flows_build` streams the `workflow_builder` turn onto + * that thread via the global `ChatRuntimeProvider` (Phase B), exactly as a + * normal chat turn streams, so the copilot reads like the real chat rather + * than a bespoke transcript. This panel keeps only the authoring concerns: + * the {@link ChatComposer} footer (mic/attachments off), the seed auto-sends, + * and the proposal-preview + capped cards pinned above the composer. * * Invariant: the copilot only PROPOSES — the agent turn itself never * persists. Accept applies the proposal to the local draft AND immediately @@ -27,18 +30,14 @@ import createDebug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { BubbleMarkdown } from '../../features/conversations/components/AgentMessageBubble'; -import { ToolTimelineBlock } from '../../features/conversations/components/ToolTimelineBlock'; -import { useStickToBottom } from '../../hooks/useStickToBottom'; +import { ChatThreadView } from '../../features/conversations/components/ChatThreadView'; import { useWorkflowBuilderChat } from '../../hooks/useWorkflowBuilderChat'; -import { unwrapToolCallEnvelope } from '../../lib/flows/copilotMessageSanitizer'; import { diffGraphs } from '../../lib/flows/graphDiff'; import type { WorkflowGraph } from '../../lib/flows/types'; import { useT } from '../../lib/i18n/I18nContext'; import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; import ChatComposer from '../chat/ChatComposer'; import Button from '../ui/Button'; -import ToolActivityChip from './ToolActivityChip'; const log = createDebug('app:flows:copilot-panel'); @@ -154,19 +153,8 @@ export default function WorkflowCopilotPanel({ fullWidth = false, }: Props) { const { t } = useT(); - const { - threadId, - sending, - turnActive, - proposal, - capped, - displayMessages, - toolTimeline, - liveResponse, - error, - send, - clearProposal, - } = useWorkflowBuilderChat(seedThreadId); + const { threadId, sending, proposal, capped, error, send, clearProposal } = + useWorkflowBuilderChat(seedThreadId); const [text, setText] = useState(''); // Report the (lazily-created) thread id up so the host persists it per flow — @@ -313,36 +301,11 @@ export default function WorkflowCopilotPanel({ onPrefillSeedConsumed?.(); }, [prefillSeed, onPrefillSeedConsumed]); - // Keep the transcript pinned to the newest message / streamed activity — - // but ONLY while the user is already at (or near) the bottom. The previous - // implementation here was an unconditional `scrollTo(bottom)` effect keyed - // on every streaming dependency (messages, tool timeline, live text, …): - // it fired on every streamed token and force-scrolled regardless of where - // the user was reading, which is what made the transcript feel "stuck" — - // any attempt to scroll up got yanked back down by the very next token. - // `useStickToBottom` is the same pinning hook the main chat surfaces use: - // it only auto-scrolls while `stickingRef` is true (user at/near bottom), - // and permanently disengages the moment the user scrolls away, so reading - // history is never fought. `resetKey` is a stable constant here — this - // panel is fully unmounted/remounted on close/reopen (see the seed refs - // above), so there's no in-place "navigation" case to reset for. - const { containerRef: scrollRef } = useStickToBottom( - displayMessages, - threadId, - 'workflow-copilot' - ); - useEffect(() => { - log( - 'scroll: stick-to-bottom deps changed messages=%d thread=%s sending=%s hasProposal=%s timeline=%d liveTextLen=%d', - displayMessages.length, - threadId ?? 'null', - sending, - Boolean(proposal), - toolTimeline.length, - liveResponse.length - ); - }, [displayMessages, threadId, sending, proposal, toolTimeline, liveResponse]); - + // Transcript rendering + scroll pinning (stick-to-bottom) are owned by the + // shared `ChatThreadView` below — the copilot no longer hand-rolls the + // transcript. This component keeps only the authoring concerns: the + // structured `flows_build` send path, the seed auto-sends, and the + // proposal / capped cards surfaced in the footer. const submit = useCallback( async (raw?: string) => { const trimmed = (raw ?? text).trim(); @@ -448,14 +411,6 @@ export default function WorkflowCopilotPanel({ }, [onReject, clearProposal]); const diff = proposal ? diffGraphs(graph, proposal.graph as WorkflowGraph) : null; - const hasTimeline = toolTimeline.length > 0; - // B25: the in-flight streaming text can also carry the raw tool-call - // envelope mid-turn — unwrap once and reuse the clean text everywhere below - // (the pre-tool streaming bubble and the shared `ToolTimelineBlock`). - const liveResponseText = unwrapToolCallEnvelope(liveResponse).text; - const hasLiveText = liveResponseText.trim().length > 0; - const isEmpty = - displayMessages.length === 0 && !proposal && !sending && !error && !hasTimeline && !hasLiveText; return (