diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50c7e89..06797e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: Verify Rust lockfile run: cargo check --locked --manifest-path apps/desktop/src-tauri/Cargo.toml + - name: Test Tauri runtime lifecycle guards + run: cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml + - name: Run repository quality gates run: | ./scripts/test.sh diff --git a/.gitignore b/.gitignore index 25f3d4d..185c89a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ local-data/ exports/ apps/desktop/src-tauri/target/ apps/desktop/src-tauri/gen/ +apps/desktop/src-tauri/permissions/ mac-agent/bin/ diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 7b2e1df..95308c7 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -47,6 +47,137 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.1", +] + [[package]] name = "atk" version = "0.18.2" @@ -142,6 +273,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -331,6 +475,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.1" @@ -701,6 +854,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -718,6 +898,37 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -839,6 +1050,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1193,6 +1417,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1647,6 +1877,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2014,10 +2250,13 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "opsmineflow" version = "0.1.0" dependencies = [ + "libc", "serde", "serde_json", + "sha2", "tauri", "tauri-build", + "tauri-plugin-single-instance", ] [[package]] @@ -2026,6 +2265,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2051,6 +2300,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2139,6 +2394,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2184,6 +2450,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2421,6 +2701,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2697,6 +2990,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -2847,6 +3150,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5edbec4ed188954a10c12c038215f8ce7606b2d5c973cd8dc43e8795065c5f2f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3050,6 +3364,22 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3150,6 +3480,19 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" @@ -3454,9 +3797,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3506,6 +3861,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4047,6 +4413,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4080,13 +4455,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4117,6 +4509,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4129,6 +4527,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4141,12 +4545,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4159,6 +4575,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4171,6 +4593,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4183,6 +4611,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4195,6 +4629,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -4329,6 +4769,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -4388,3 +4889,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow 1.0.3", +] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 89d2b3c..1dcabd7 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -13,4 +13,6 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" - +libc = "0.2" +sha2 = "0.10" +tauri-plugin-single-instance = "2" diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 9d39928..f2777d1 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -1,4 +1,6 @@ fn main() { - tauri_build::build() + tauri_build::try_build(tauri_build::Attributes::new().app_manifest( + tauri_build::AppManifest::new().commands(&["runtime_status", "repair_runtime_state"]), + )) + .expect("failed to build Tauri capabilities"); } - diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000..73f9e91 --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main-window-runtime-status", + "description": "Lets the main OpsMineFlow window read redacted local runtime status and explicitly repair quarantined runtime state.", + "windows": ["main"], + "permissions": ["allow-runtime-status", "allow-repair-runtime-state"] +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 519b5f2..055a88c 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,8 +1,45 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -fn main() { - tauri::Builder::default() - .run(tauri::generate_context!()) - .expect("failed to run OpsMineFlow desktop app"); +mod runtime; + +use tauri::{Manager, RunEvent}; + +#[tauri::command] +fn runtime_status(state: tauri::State<'_, runtime::RuntimeState>) -> runtime::RuntimeStatus { + state.status() } +#[tauri::command] +fn repair_runtime_state( + state: tauri::State<'_, runtime::RuntimeState>, + app: tauri::AppHandle, +) -> runtime::RuntimeStatus { + state.repair(&app) +} + +fn main() { + let app = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + })) + .manage(runtime::RuntimeState::default()) + .setup(|app| { + app.state::() + .initialize(&app.handle()); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + runtime_status, + repair_runtime_state + ]) + .build(tauri::generate_context!()) + .expect("failed to build OpsMineFlow desktop app"); + app.run(|app, event| { + if matches!(event, RunEvent::Exit) { + app.state::().shutdown(); + } + }) +} diff --git a/apps/desktop/src-tauri/src/runtime.rs b/apps/desktop/src-tauri/src/runtime.rs new file mode 100644 index 0000000..36062c6 --- /dev/null +++ b/apps/desktop/src-tauri/src/runtime.rs @@ -0,0 +1,976 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::{AppHandle, Manager}; + +const LOCAL_API_HOST: &str = "127.0.0.1"; +const LOCAL_API_PORT: u16 = 8765; +const READY_TIMEOUT: Duration = Duration::from_secs(5); +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3); +const HEALTH_FAILURE_THRESHOLD: u8 = 3; + +#[derive(Clone, Serialize)] +pub struct RuntimeStatus { + pub state: String, + pub endpoint: String, + pub recovery_action: String, +} + +impl RuntimeStatus { + fn ready() -> Self { + Self { + state: "ready".to_owned(), + endpoint: endpoint().to_string(), + recovery_action: "none".to_owned(), + } + } + + fn unavailable(recovery_action: &str) -> Self { + Self { + state: "unavailable".to_owned(), + endpoint: endpoint().to_string(), + recovery_action: recovery_action.to_owned(), + } + } + + fn port_collision() -> Self { + Self { + state: "port_collision".to_owned(), + endpoint: endpoint().to_string(), + recovery_action: "close_conflicting_app".to_owned(), + } + } + + fn stopped() -> Self { + Self { + state: "stopped".to_owned(), + endpoint: endpoint().to_string(), + recovery_action: "restart".to_owned(), + } + } +} + +pub struct RuntimeState { + inner: Mutex, +} + +struct RuntimeInner { + child: Option, + paths: Option, + endpoint: SocketAddr, + runtime_nonce: Option, + session_secret: Option, + health_failure_count: u8, + repair_in_progress: bool, + status: RuntimeStatus, +} + +struct SessionSecret(String); + +#[derive(Clone)] +struct RuntimePaths { + log_dir: PathBuf, + data_dir: PathBuf, + marker_path: PathBuf, +} + +#[derive(Serialize, Deserialize)] +struct RuntimeMarker { + pid: u32, + nonce: String, +} + +struct SidecarCommand { + program: PathBuf, + pythonpath: Option, + is_development: bool, +} + +#[derive(Deserialize)] +struct HealthPayload { + status: String, + bind: String, + local_only: bool, + #[serde(default)] + runtime: Option, +} + +#[derive(Deserialize)] +struct RuntimeIdentity { + nonce: String, + pid: u32, +} + +impl Default for RuntimeState { + fn default() -> Self { + Self { + inner: Mutex::new(RuntimeInner { + child: None, + paths: None, + endpoint: endpoint(), + runtime_nonce: None, + session_secret: None, + health_failure_count: 0, + repair_in_progress: false, + status: RuntimeStatus::stopped(), + }), + } + } +} + +impl RuntimeState { + pub fn initialize(&self, app: &AppHandle) { + let paths = match RuntimePaths::prepare(app) { + Ok(paths) => paths, + Err(_) => { + self.set_unavailable_without_paths("restart"); + return; + } + }; + if recover_owned_orphan(&paths).is_err() { + self.set_unavailable(paths, "repair_runtime_state"); + return; + } + + let sidecar = match resolve_sidecar(app) { + Ok(sidecar) => sidecar, + Err(recovery_action) => { + self.set_unavailable(paths, &recovery_action); + return; + } + }; + + if endpoint_is_open() { + if let Ok(mut inner) = self.inner.lock() { + inner.paths = Some(paths); + inner.runtime_nonce = None; + inner.session_secret = None; + inner.health_failure_count = 0; + inner.status = RuntimeStatus::port_collision(); + } + return; + } + + let nonce = match random_hex(32) { + Ok(nonce) => nonce, + Err(_) => { + self.set_unavailable(paths, "restart"); + return; + } + }; + let secret = match random_hex(32) { + Ok(secret) => secret, + Err(_) => { + self.set_unavailable(paths, "restart"); + return; + } + }; + let mut child = match spawn_sidecar(&sidecar, &paths, &nonce, &secret) { + Ok(child) => child, + Err(_) => { + self.set_unavailable(paths, "restart"); + return; + } + }; + + let marker = RuntimeMarker { + pid: child.id(), + nonce: nonce.clone(), + }; + if write_marker(&paths.marker_path, &marker).is_err() { + let _ = graceful_stop(&mut child); + self.set_unavailable(paths, "restart"); + return; + } + if !wait_for_ready(&mut child, &nonce) { + if graceful_stop(&mut child).is_ok() { + let _ = fs::remove_file(&paths.marker_path); + } + self.set_unavailable(paths, "restart"); + return; + } + + if let Ok(mut inner) = self.inner.lock() { + inner.child = Some(child); + inner.paths = Some(paths); + inner.runtime_nonce = Some(nonce); + inner.session_secret = Some(SessionSecret(secret)); + inner.health_failure_count = 0; + inner.status = RuntimeStatus::ready(); + } else { + if graceful_stop(&mut child).is_ok() { + let _ = fs::remove_file(&paths.marker_path); + } + } + } + + pub fn status(&self) -> RuntimeStatus { + let Ok(mut inner) = self.inner.lock() else { + return RuntimeStatus::unavailable("restart"); + }; + let exited = match inner.child.as_mut() { + Some(child) => child + .try_wait() + .map(|status| status.is_some()) + .unwrap_or(false), + None => false, + }; + let health_result = match ( + inner.child.as_ref(), + inner.runtime_nonce.as_deref(), + inner.status.state.as_str(), + ) { + (Some(child), Some(nonce), "ready") => match read_health_identity() { + Some(identity) if identity.pid == child.id() && identity.nonce == nonce => { + HealthCheck::MatchesOwner + } + Some(_) => HealthCheck::OwnershipMismatch, + None => HealthCheck::Unavailable, + }, + (_, _, "ready") => HealthCheck::OwnershipMismatch, + _ => HealthCheck::NotChecked, + }; + let has_session_secret = inner + .session_secret + .as_ref() + .is_some_and(|secret| !secret.0.is_empty()); + let should_stop_for_health = should_stop_after_health_check( + &mut inner.health_failure_count, + health_result, + has_session_secret, + ); + if exited || (inner.status.state == "ready" && should_stop_for_health) { + let mut child = inner.child.take(); + let paths = inner.paths.clone(); + inner.session_secret = None; + inner.runtime_nonce = None; + inner.health_failure_count = 0; + inner.status = RuntimeStatus::unavailable("restart"); + if exited { + if let Some(paths) = paths { + let _ = fs::remove_file(&paths.marker_path); + } + } else if let Some(child) = child.as_mut() { + if graceful_stop(child).is_ok() { + if let Some(paths) = paths { + let _ = fs::remove_file(&paths.marker_path); + } + } + } + } + let mut status = inner.status.clone(); + status.endpoint = inner.endpoint.to_string(); + status + } + + pub fn shutdown(&self) { + let Ok(mut inner) = self.inner.lock() else { + return; + }; + let paths = inner.paths.clone(); + let stopped = match inner.child.as_mut() { + Some(child) => graceful_stop(child).is_ok(), + None => true, + }; + if stopped { + inner.child = None; + if let Some(paths) = paths { + let _ = fs::remove_file(&paths.marker_path); + } + } + inner.session_secret = None; + inner.runtime_nonce = None; + inner.health_failure_count = 0; + inner.repair_in_progress = false; + inner.status = if stopped { + RuntimeStatus::stopped() + } else { + RuntimeStatus::unavailable("restart") + }; + } + + pub fn repair(&self, app: &AppHandle) -> RuntimeStatus { + { + let Ok(mut inner) = self.inner.lock() else { + return RuntimeStatus::unavailable("repair_runtime_state"); + }; + if inner.repair_in_progress + || inner.status.recovery_action != "repair_runtime_state" + || inner.child.is_some() + { + return status_for_inner(&inner); + } + if endpoint_is_open() { + inner.status = RuntimeStatus::port_collision(); + return status_for_inner(&inner); + } + let Some(paths) = inner.paths.clone() else { + return RuntimeStatus::unavailable("repair_runtime_state"); + }; + if quarantine_marker(&paths.marker_path).is_err() { + return status_for_inner(&inner); + } + inner.repair_in_progress = true; + } + + // The unverified record is retained as a private quarantine artifact. + // A second startup check still detects a listener that appeared during repair. + self.initialize(app); + let Ok(mut inner) = self.inner.lock() else { + return RuntimeStatus::unavailable("restart"); + }; + inner.repair_in_progress = false; + status_for_inner(&inner) + } + + fn set_unavailable(&self, paths: RuntimePaths, recovery_action: &str) { + if let Ok(mut inner) = self.inner.lock() { + inner.paths = Some(paths); + inner.runtime_nonce = None; + inner.session_secret = None; + inner.health_failure_count = 0; + inner.status = RuntimeStatus::unavailable(recovery_action); + } + } + + fn set_unavailable_without_paths(&self, recovery_action: &str) { + if let Ok(mut inner) = self.inner.lock() { + inner.child = None; + inner.runtime_nonce = None; + inner.session_secret = None; + inner.health_failure_count = 0; + inner.status = RuntimeStatus::unavailable(recovery_action); + } + } +} + +impl RuntimePaths { + fn prepare(app: &AppHandle) -> Result { + let root = app.path().app_local_data_dir().map_err(|error| { + format!("could not resolve the application data directory: {error}") + })?; + let runtime_dir = root.join("runtime"); + let log_dir = root.join("logs"); + let data_dir = root.join("data"); + for directory in [&root, &runtime_dir, &log_dir, &data_dir] { + create_private_directory(directory)?; + } + let marker_path = runtime_dir.join("sidecar-owner.json"); + verify_marker_writable(&marker_path)?; + Ok(Self { + marker_path, + log_dir, + data_dir, + }) + } +} + +fn resolve_sidecar(app: &AppHandle) -> Result { + if cfg!(debug_assertions) { + let program = std::env::var_os("OPSMINEFLOW_DEV_SIDECAR") + .map(PathBuf::from) + .ok_or_else(|| "development_setup".to_owned())?; + let pythonpath = std::env::var("OPSMINEFLOW_DEV_PYTHONPATH") + .map_err(|_| "development_setup".to_owned())?; + if !program.is_absolute() || !program.is_file() || pythonpath.trim().is_empty() { + return Err("development_setup".to_owned()); + } + return Ok(SidecarCommand { + program, + pythonpath: Some(pythonpath), + is_development: true, + }); + } + + let resource_dir = app + .path() + .resource_dir() + .map_err(|_| "reinstall".to_owned())?; + let program = resource_dir.join("opsmineflow-local-api"); + let checksum_manifest = resource_dir.join("opsmineflow-local-api.sha256"); + if !verify_packaged_sidecar(&resource_dir, &program, &checksum_manifest) { + return Err("reinstall".to_owned()); + } + Ok(SidecarCommand { + program, + pythonpath: None, + is_development: false, + }) +} + +fn spawn_sidecar( + sidecar: &SidecarCommand, + paths: &RuntimePaths, + nonce: &str, + secret: &str, +) -> Result { + let log_path = paths.log_dir.join("local-api.log"); + let stdout = private_append_file(&log_path)?; + let stderr = private_append_file(&log_path)?; + let mut command = Command::new(&sidecar.program); + command + .env_clear() + .env("OPSMINEFLOW_API_HOST", LOCAL_API_HOST) + .env("OPSMINEFLOW_API_PORT", LOCAL_API_PORT.to_string()) + .env("OPSMINEFLOW_DATA_DIR", &paths.data_dir) + .env("OPSMINEFLOW_LOG_DIR", &paths.log_dir) + .env("OPSMINEFLOW_RUNTIME_NONCE", nonce) + .env("OPSMINEFLOW_RUNTIME_SECRET", secret) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + if sidecar.is_development { + command.args(["-m", "opsmineflow_api"]).env( + "PYTHONPATH", + sidecar.pythonpath.as_deref().unwrap_or_default(), + ); + } + command + .spawn() + .map_err(|_| "could not start the local runtime".to_owned()) +} + +fn recover_owned_orphan(paths: &RuntimePaths) -> Result<(), String> { + verify_marker_is_regular_file(&paths.marker_path)?; + let contents = match fs::read_to_string(&paths.marker_path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("could not read previous runtime state: {error}")), + }; + let marker = serde_json::from_str::(&contents) + .map_err(|_| "could not verify previous runtime ownership state".to_owned())?; + if !pid_exists(marker.pid)? { + return remove_marker(&paths.marker_path); + } + let identity = read_health_identity() + .ok_or_else(|| "could not verify ownership of the previous local runtime".to_owned())?; + if identity.pid != marker.pid || identity.nonce != marker.nonce { + return Err("previous local runtime ownership does not match its marker".to_owned()); + } + terminate_pid(marker.pid)?; + wait_for_endpoint_to_close()?; + if pid_exists(marker.pid)? { + return Err("the previous local runtime did not exit after shutdown".to_owned()); + } + remove_marker(&paths.marker_path) +} + +fn wait_for_ready(child: &mut Child, nonce: &str) -> bool { + let started_at = Instant::now(); + while started_at.elapsed() < READY_TIMEOUT { + if let Some(identity) = read_health_identity() { + if identity.nonce == nonce && identity.pid == child.id() { + return true; + } + } + if child.try_wait().ok().flatten().is_some() { + return false; + } + thread::sleep(Duration::from_millis(100)); + } + false +} + +fn wait_for_endpoint_to_close() -> Result<(), String> { + let started_at = Instant::now(); + while started_at.elapsed() < SHUTDOWN_TIMEOUT { + if !endpoint_is_open() { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + Err("the previous local runtime did not stop in time".to_owned()) +} + +fn graceful_stop(child: &mut Child) -> Result<(), String> { + terminate_pid(child.id())?; + let started_at = Instant::now(); + while started_at.elapsed() < SHUTDOWN_TIMEOUT { + if child + .try_wait() + .map_err(|error| error.to_string())? + .is_some() + { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + child.kill().map_err(|error| error.to_string())?; + child.wait().map_err(|error| error.to_string())?; + Ok(()) +} + +fn terminate_pid(pid: u32) -> Result<(), String> { + let pid = checked_pid(pid)?; + let result = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error().to_string()) + } +} + +fn pid_exists(pid: u32) -> Result { + let pid = checked_pid(pid)?; + let result = unsafe { libc::kill(pid as i32, 0) }; + if result == 0 { + return Ok(true); + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => Ok(false), + Some(libc::EPERM) => Ok(true), + _ => Err("could not check whether the previous runtime still exists".to_owned()), + } +} + +fn checked_pid(pid: u32) -> Result { + if pid == 0 || pid > i32::MAX as u32 { + return Err("runtime ownership state contains an invalid process identifier".to_owned()); + } + Ok(pid) +} + +fn remove_marker(path: &Path) -> Result<(), String> { + fs::remove_file(path).map_err(|error| format!("could not clear stale runtime state: {error}")) +} + +fn quarantine_marker(path: &Path) -> Result { + let quarantined_path = + path.with_file_name(format!("sidecar-owner.quarantined.{}.json", random_hex(8)?)); + verify_marker_is_regular_file(path)?; + set_private_file_permissions(path)?; + fs::rename(path, &quarantined_path) + .map_err(|error| format!("could not quarantine unverified runtime state: {error}"))?; + Ok(quarantined_path) +} + +fn verify_marker_is_regular_file(path: &Path) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_file() && !metadata.file_type().is_symlink() => { + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + _ => Err("runtime ownership state is not a private regular file".to_owned()), + } +} + +fn endpoint_is_open() -> bool { + endpoint_is_open_at(endpoint()) +} + +fn endpoint_is_open_at(address: SocketAddr) -> bool { + TcpStream::connect_timeout(&address, Duration::from_millis(150)).is_ok() +} + +fn read_health_identity() -> Option { + read_health_identity_at(endpoint()) +} + +fn read_health_identity_at(address: SocketAddr) -> Option { + let mut stream = TcpStream::connect_timeout(&address, Duration::from_millis(250)).ok()?; + stream + .set_read_timeout(Some(Duration::from_millis(250))) + .ok()?; + stream + .set_write_timeout(Some(Duration::from_millis(250))) + .ok()?; + stream + .write_all(b"GET /runtime/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") + .ok()?; + let mut response = String::new(); + stream.read_to_string(&mut response).ok()?; + let (_, body) = response.split_once("\r\n\r\n")?; + let health: HealthPayload = serde_json::from_str(body).ok()?; + if health.status != "ok" || health.bind != LOCAL_API_HOST || !health.local_only { + return None; + } + health.runtime +} + +fn write_marker(path: &Path, marker: &RuntimeMarker) -> Result<(), String> { + let temporary_path = path.with_extension("json.tmp"); + fs::write( + &temporary_path, + serde_json::to_vec(marker).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + set_private_file_permissions(&temporary_path)?; + fs::rename(&temporary_path, path).map_err(|error| error.to_string()) +} + +fn verify_marker_writable(path: &Path) -> Result<(), String> { + let probe_name = format!( + ".{}.{}.probe", + path.file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("runtime"), + random_hex(8)? + ); + let probe_path = path.with_file_name(probe_name); + let _probe = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&probe_path) + .map_err(|error| format!("could not prepare private runtime state: {error}"))?; + fs::remove_file(probe_path) + .map_err(|error| format!("could not clear runtime state probe: {error}")) +} + +fn random_hex(byte_count: usize) -> Result { + let mut bytes = vec![0_u8; byte_count]; + File::open("/dev/urandom") + .and_then(|mut random| random.read_exact(&mut bytes)) + .map_err(|error| format!("could not obtain runtime entropy: {error}"))?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn create_private_directory(path: &Path) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("could not create runtime directory: {error}"))?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("could not secure runtime directory: {error}")) +} + +fn private_append_file(path: &Path) -> Result { + let file = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(path) + .map_err(|error| format!("could not open local runtime log: {error}"))?; + set_private_file_permissions(path)?; + Ok(file) +} + +fn set_private_file_permissions(path: &Path) -> Result<(), String> { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("could not secure runtime file: {error}")) +} + +fn verify_packaged_sidecar(resource_dir: &Path, program: &Path, checksum_manifest: &Path) -> bool { + let resource_dir = match resource_dir.canonicalize() { + Ok(path) => path, + Err(_) => return false, + }; + let program_metadata = match fs::symlink_metadata(program) { + Ok(metadata) if metadata.file_type().is_file() && !metadata.file_type().is_symlink() => { + metadata + } + _ => return false, + }; + let manifest_metadata = match fs::symlink_metadata(checksum_manifest) { + Ok(metadata) if metadata.file_type().is_file() && !metadata.file_type().is_symlink() => { + metadata + } + _ => return false, + }; + if program_metadata.permissions().mode() & 0o022 != 0 + || manifest_metadata.permissions().mode() & 0o022 != 0 + { + return false; + } + let program = match program.canonicalize() { + Ok(path) if path.starts_with(&resource_dir) => path, + _ => return false, + }; + let expected = match fs::read_to_string(checksum_manifest) + .ok() + .and_then(|contents| contents.split_whitespace().next().map(str::to_owned)) + { + Some(value) + if value.len() == 64 + && value.chars().all(|character| character.is_ascii_hexdigit()) => + { + value + } + _ => return false, + }; + sha256_file(&program) + .map(|actual| actual.eq_ignore_ascii_case(&expected)) + .unwrap_or(false) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher)?; + Ok(hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +fn endpoint() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), LOCAL_API_PORT) +} + +fn status_for_inner(inner: &RuntimeInner) -> RuntimeStatus { + let mut status = inner.status.clone(); + status.endpoint = inner.endpoint.to_string(); + status +} + +enum HealthCheck { + MatchesOwner, + OwnershipMismatch, + Unavailable, + NotChecked, +} + +fn should_stop_after_health_check( + failure_count: &mut u8, + health_check: HealthCheck, + has_session_secret: bool, +) -> bool { + match health_check { + HealthCheck::MatchesOwner => { + *failure_count = 0; + !has_session_secret + } + HealthCheck::OwnershipMismatch => true, + HealthCheck::Unavailable => { + *failure_count = failure_count.saturating_add(1); + *failure_count >= HEALTH_FAILURE_THRESHOLD + } + HealthCheck::NotChecked => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn runtime_status_never_serializes_a_runtime_secret() { + let serialized = serde_json::to_string(&RuntimeStatus::ready()).expect("status serializes"); + + assert!(!serialized.contains("secret")); + assert!(!serialized.contains("nonce")); + assert!(!serialized.contains("pid")); + } + + #[test] + fn runtime_status_uses_a_loopback_endpoint() { + let status = RuntimeStatus::ready(); + + assert_eq!(status.endpoint, "127.0.0.1:8765"); + assert_eq!(status.recovery_action, "none"); + } + + #[test] + fn local_listener_is_detected_as_a_port_collision_candidate() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind loopback listener"); + + assert!(endpoint_is_open_at( + listener.local_addr().expect("listener address") + )); + } + + #[test] + fn runtime_health_identity_requires_the_constant_time_payload() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind loopback listener"); + let address = listener.local_addr().expect("listener address"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept health request"); + let mut request = [0_u8; 512]; + let _ = stream.read(&mut request); + let body = r#"{"status":"ok","bind":"127.0.0.1","local_only":true,"runtime":{"nonce":"owner-nonce","pid":4242}}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("write health response"); + }); + + let identity = read_health_identity_at(address).expect("parse runtime identity"); + server.join().expect("health server joins"); + + assert_eq!(identity.nonce, "owner-nonce"); + assert_eq!(identity.pid, 4242); + } + + #[test] + fn unexpected_child_exit_revokes_the_runtime_state() { + let state = RuntimeState::default(); + let child = Command::new("/usr/bin/true") + .spawn() + .expect("start short child"); + thread::sleep(Duration::from_millis(50)); + { + let mut inner = state.inner.lock().expect("runtime lock"); + inner.child = Some(child); + inner.runtime_nonce = Some("owner-nonce".to_owned()); + inner.session_secret = Some(SessionSecret("session-secret".to_owned())); + inner.status = RuntimeStatus::ready(); + } + + let status = state.status(); + + assert_eq!(status.state, "unavailable"); + assert_eq!(status.recovery_action, "restart"); + } + + #[test] + fn transient_health_failures_do_not_stop_a_live_sidecar() { + let state = RuntimeState::default(); + let child = Command::new("/bin/sh") + .args(["-c", "trap 'exit 0' TERM; while :; do sleep 1; done"]) + .spawn() + .expect("start long-running child"); + { + let mut inner = state.inner.lock().expect("runtime lock"); + inner.child = Some(child); + inner.runtime_nonce = Some("owner-nonce".to_owned()); + inner.session_secret = Some(SessionSecret("session-secret".to_owned())); + inner.status = RuntimeStatus::ready(); + } + + let first = state.status(); + let second = state.status(); + let third = state.status(); + + assert_eq!(first.state, "ready"); + assert_eq!(second.state, "ready"); + assert_eq!(third.state, "unavailable"); + } + + #[test] + fn successful_health_check_resets_transient_failure_count() { + let mut failure_count = HEALTH_FAILURE_THRESHOLD - 1; + + assert!(!should_stop_after_health_check( + &mut failure_count, + HealthCheck::MatchesOwner, + true, + )); + assert_eq!(failure_count, 0); + assert!(!should_stop_after_health_check( + &mut failure_count, + HealthCheck::Unavailable, + true, + )); + assert_eq!(failure_count, 1); + } + + #[test] + fn unverifiable_orphan_marker_is_retained_for_safe_recovery() { + let root = std::env::temp_dir().join(format!( + "opsmineflow-runtime-marker-test-{}", + random_hex(8).expect("entropy") + )); + fs::create_dir_all(&root).expect("create test directory"); + let marker_path = root.join("sidecar-owner.json"); + fs::write(&marker_path, b"not valid JSON").expect("write corrupt marker"); + let paths = RuntimePaths { + log_dir: root.join("logs"), + data_dir: root.join("data"), + marker_path: marker_path.clone(), + }; + + assert!(recover_owned_orphan(&paths).is_err()); + assert!(marker_path.exists()); + + fs::remove_dir_all(&root).expect("remove test directory"); + } + + #[test] + fn quarantining_unverified_marker_preserves_the_original_record() { + let root = std::env::temp_dir().join(format!( + "opsmineflow-runtime-quarantine-test-{}", + random_hex(8).expect("entropy") + )); + fs::create_dir_all(&root).expect("create test directory"); + let marker_path = root.join("sidecar-owner.json"); + fs::write(&marker_path, b"not valid JSON").expect("write corrupt marker"); + + let quarantined_path = quarantine_marker(&marker_path).expect("quarantine marker"); + + assert!(!marker_path.exists()); + assert_eq!( + fs::read(&quarantined_path).expect("read quarantined marker"), + b"not valid JSON" + ); + + fs::remove_dir_all(&root).expect("remove test directory"); + } + + #[test] + fn repair_refuses_to_follow_a_marker_symlink() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!( + "opsmineflow-runtime-symlink-test-{}", + random_hex(8).expect("entropy") + )); + fs::create_dir_all(&root).expect("create test directory"); + let target_path = root.join("unrelated.json"); + let marker_path = root.join("sidecar-owner.json"); + fs::write(&target_path, b"not valid JSON").expect("write target file"); + symlink(&target_path, &marker_path).expect("create marker symlink"); + + assert!(quarantine_marker(&marker_path).is_err()); + assert!(marker_path.is_symlink()); + assert_eq!( + fs::read(&target_path).expect("read target file"), + b"not valid JSON" + ); + + fs::remove_dir_all(&root).expect("remove test directory"); + } + + #[test] + fn current_process_is_reported_as_existing() { + assert!(pid_exists(std::process::id()).expect("check current process")); + } + + #[test] + fn graceful_stop_waits_for_the_child_to_exit() { + let mut child = Command::new("/bin/sh") + .args(["-c", "trap 'exit 0' TERM; while :; do sleep 1; done"]) + .spawn() + .expect("start long-running child"); + + graceful_stop(&mut child).expect("graceful child shutdown"); + + assert!(child.try_wait().expect("read child status").is_some()); + } + + #[test] + fn packaged_sidecar_checksum_rejects_modified_contents() { + let root = std::env::temp_dir().join(format!( + "opsmineflow-runtime-test-{}", + random_hex(8).expect("entropy") + )); + fs::create_dir_all(&root).expect("create test resource directory"); + let program = root.join("opsmineflow-local-api"); + let manifest = root.join("opsmineflow-local-api.sha256"); + fs::write(&program, b"trusted sidecar").expect("write sidecar"); + fs::set_permissions(&program, fs::Permissions::from_mode(0o755)) + .expect("secure sidecar permissions"); + let checksum = sha256_file(&program).expect("hash sidecar"); + fs::write(&manifest, format!("{checksum} opsmineflow-local-api\n")) + .expect("write manifest"); + fs::set_permissions(&manifest, fs::Permissions::from_mode(0o644)) + .expect("secure manifest permissions"); + + assert!(verify_packaged_sidecar(&root, &program, &manifest)); + fs::write(&program, b"modified sidecar").expect("modify sidecar"); + assert!(!verify_packaged_sidecar(&root, &program, &manifest)); + + fs::remove_dir_all(&root).expect("remove test resource directory"); + } +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index a0ddc1a..f95317a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -4,6 +4,7 @@ import { deleteLocalData, excludeEvent, exportArtifact, + getNativeRuntimeStatus, importActivityWatchLocal, importEvents, loadDashboardData, @@ -12,6 +13,7 @@ import { previewActivityWatchLocal, previewImport, previewExport, + repairNativeRuntimeState, resumeRecording, runDiagnosticChecks, saveAutomationReview, @@ -43,6 +45,7 @@ import type { ImportPreview, ProcessMap, RecordingStatus, + RuntimeStatus, Summary } from "./types"; @@ -124,6 +127,17 @@ type CsvMappingPreset = { timezone: string; }; +function runtimeRecoveryMessage(status: RuntimeStatus, t: (key: TranslationKey) => string): string { + const keys: Record = { + reinstall: "message.runtimeReinstall", + close_conflicting_app: "message.runtimePortCollision", + restart: "message.runtimeRestart", + repair_runtime_state: "message.runtimeRepairState", + development_setup: "message.runtimeDevelopmentSetup" + }; + return t(keys[status.recovery_action] || "message.runtimeUnavailable"); +} + function loadRecordingTemplates(): string[] { try { const raw = window.localStorage.getItem(RECORDING_TEMPLATES_KEY); @@ -174,11 +188,17 @@ export function App() { const [actionMessage, setActionMessage] = useState(""); const [loading, setLoading] = useState(true); const [working, setWorking] = useState(false); + const [runtimeStatus, setRuntimeStatus] = useState(null); async function refresh(silent = false) { if (!silent) setLoading(true); setError(""); try { + const runtime = await getNativeRuntimeStatus(); + setRuntimeStatus(runtime); + if (runtime && runtime.state !== "ready") { + throw new Error(runtimeRecoveryMessage(runtime, t)); + } setData(await loadDashboardData()); } catch (err) { setError(err instanceof Error ? err.message : t("message.apiUnavailable", { error: "" })); @@ -187,10 +207,42 @@ export function App() { } } + async function repairRuntimeState() { + if (!window.confirm(t("confirm.repairRuntimeState"))) return; + setWorking(true); + setError(""); + try { + const runtime = await repairNativeRuntimeState(); + setRuntimeStatus(runtime); + if (runtime && runtime.state !== "ready") { + setError(runtimeRecoveryMessage(runtime, t)); + return; + } + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : t("message.runtimeUnavailable")); + } finally { + setWorking(false); + } + } + useEffect(() => { void refresh(); }, []); + useEffect(() => { + if (data?.recording.active) return; + const timer = window.setInterval(() => { + void getNativeRuntimeStatus() + .then((runtime) => { + setRuntimeStatus(runtime); + if (runtime && runtime.state !== "ready") setError(runtimeRecoveryMessage(runtime, t)); + }) + .catch(() => setError(t("message.runtimeUnavailable"))); + }, 2000); + return () => window.clearInterval(timer); + }, [data?.recording.active, t]); + useEffect(() => { if (!data?.recording.active) return; const timer = window.setInterval(() => void refresh(true), 2000); @@ -416,7 +468,16 @@ export function App() { ) : null} - {error ?
{t("message.apiUnavailable", { error })}
: null} + {error ? ( +
+ {t("message.apiUnavailable", { error })} + {runtimeStatus?.recovery_action === "repair_runtime_state" ? ( + + ) : null} +
+ ) : null} {actionMessage ?
{actionMessage}
: null} {loading && !data ?
{t("message.loading")}
: null} diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 382e6e6..c6e9c9a 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -18,11 +18,23 @@ import type { ImportPreview, ProcessMap, RecordingStatus, + RuntimeStatus, Summary } from "./types"; +import { invoke, isTauri } from "@tauri-apps/api/core"; const API_BASE = import.meta.env.VITE_API_BASE ?? "http://127.0.0.1:8765"; +export async function getNativeRuntimeStatus(): Promise { + if (!isTauri()) return null; + return invoke("runtime_status"); +} + +export async function repairNativeRuntimeState(): Promise { + if (!isTauri()) return null; + return invoke("repair_runtime_state"); +} + async function getJson(path: string): Promise { const response = await fetch(`${API_BASE}${path}`); if (!response.ok) { diff --git a/apps/desktop/src/locales/en.json b/apps/desktop/src/locales/en.json index fb6bb88..ed6f16b 100644 --- a/apps/desktop/src/locales/en.json +++ b/apps/desktop/src/locales/en.json @@ -55,7 +55,15 @@ "nav.reports": "Reports", "nav.settings": "Settings", "message.apiUnavailable": "Local API is not available: {error}", + "message.runtimeUnavailable": "OpsMineFlow local runtime is unavailable. Restart the app. If the problem continues, reinstall the official app package.", + "message.runtimeReinstall": "OpsMineFlow local runtime is missing or unsafe to start. Quit the app and reinstall the official app package.", + "message.runtimePortCollision": "Another program is using the OpsMineFlow local port. Quit the other program, then restart OpsMineFlow.", + "message.runtimeRestart": "OpsMineFlow local runtime stopped unexpectedly. Restart the app. If it repeats, create a support bundle before reinstalling.", + "message.runtimeRepairState": "OpsMineFlow could not safely verify a previous local runtime. Quit the app, ensure no other OpsMineFlow process is running, then reopen it. Reinstall if this continues.", + "message.runtimeDevelopmentSetup": "Development runtime is not configured. Start Tauri with ./scripts/dev_desktop.sh.", "message.loading": "Loading local analysis...", + "action.repairRuntimeState": "Repair local runtime state", + "confirm.repairRuntimeState": "Repair local runtime state now? OpsMineFlow will retain the unverified record privately, then start a new local runtime only if its port is free.", "message.actionFailed": "Action failed", "message.previewFailed": "Preview failed", "message.exportPreviewFailed": "Export preview failed", diff --git a/apps/desktop/src/locales/ja.json b/apps/desktop/src/locales/ja.json index 1fe644c..2611b76 100644 --- a/apps/desktop/src/locales/ja.json +++ b/apps/desktop/src/locales/ja.json @@ -55,7 +55,15 @@ "nav.reports": "レポート", "nav.settings": "設定", "message.apiUnavailable": "ローカルAPIへ接続できない: {error}", + "message.runtimeUnavailable": "OpsMineFlowのローカル処理を利用できない。アプリを再起動し、解消しない場合は公式アプリを再インストールして。", + "message.runtimeReinstall": "OpsMineFlowのローカル処理コンポーネントが見つからないか、安全に起動できない。アプリを終了し、公式アプリを再インストールして。", + "message.runtimePortCollision": "別のプログラムがOpsMineFlowのローカルポートを使っている。そのプログラムを終了してからOpsMineFlowを再起動して。", + "message.runtimeRestart": "OpsMineFlowのローカル処理が予期せず停止した。アプリを再起動し、繰り返す場合は再インストール前にsupport bundleを作成して。", + "message.runtimeRepairState": "前回のOpsMineFlowローカル処理を安全に確認できない。アプリを終了し、ほかのOpsMineFlowプロセスが動いていないことを確認してから開き直して。続く場合は再インストールして。", + "message.runtimeDevelopmentSetup": "開発用ローカル処理が設定されていない。./scripts/dev_desktop.sh でTauriを起動して。", "message.loading": "ローカル分析を読み込み中...", + "action.repairRuntimeState": "ローカル処理の状態を修復", + "confirm.repairRuntimeState": "ローカル処理の状態を修復する?検証できない記録は非公開のまま保持し、ポートが空いている場合だけ新しいローカル処理を起動する。", "message.actionFailed": "操作に失敗した", "message.previewFailed": "確認用データの作成に失敗した", "message.exportPreviewFailed": "出力内容の確認に失敗した", diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 40bd3b5..4832750 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -7,6 +7,12 @@ export type Health = { event_count: number; }; +export type RuntimeStatus = { + state: "ready" | "unavailable" | "port_collision" | "stopped" | string; + endpoint: string; + recovery_action: "none" | "reinstall" | "close_conflicting_app" | "restart" | "development_setup" | string; +}; + export type EventRecord = { event_id: string; case_id: string; diff --git a/docs/operations/RUNBOOK.md b/docs/operations/RUNBOOK.md index b3104de..00dba7c 100644 --- a/docs/operations/RUNBOOK.md +++ b/docs/operations/RUNBOOK.md @@ -137,4 +137,14 @@ Build macOS release artifacts: ./scripts/package_macos.sh ``` +Run the Tauri desktop shell with its explicitly owned development sidecar: + +```bash +./scripts/dev_desktop.sh +``` + +The development command is intentionally separate from `npm run tauri -- dev`: it passes the local Python interpreter and source import paths only to the Rust-owned child process. A packaged app never falls back to a repository checkout, Terminal, Node.js, or a system Python. Until #78 bundles the signed local runtime, a packaged build fails closed with a recovery action instead of starting an arbitrary executable. + +If the desktop app asks to repair prior runtime state, first make sure no other OpsMineFlow process is running. Then choose **Repair local runtime state** and confirm the safety prompt. OpsMineFlow keeps the unverified ownership record in a private quarantine location and starts a replacement only after confirming that the local port is free. Do not delete runtime ownership records manually. + See [PACKAGING_MACOS.md](PACKAGING_MACOS.md) before client or public distribution. diff --git a/scripts/dev_desktop.sh b/scripts/dev_desktop.sh new file mode 100755 index 0000000..33735aa --- /dev/null +++ b/scripts/dev_desktop.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DESKTOP_DIR="$ROOT_DIR/apps/desktop" +PYTHON_BIN="$ROOT_DIR/.venv/bin/python" + +if [[ ! -x "$PYTHON_BIN" ]]; then + echo "ERROR: Missing $PYTHON_BIN. Run ./scripts/install_mac.sh first." >&2 + exit 1 +fi + +export OPSMINEFLOW_DEV_SIDECAR="$PYTHON_BIN" +export OPSMINEFLOW_DEV_PYTHONPATH="$ROOT_DIR/services/mining-core/src:$ROOT_DIR/services/local-api/src:$ROOT_DIR/packages/drawio-exporter/src" + +cd "$ROOT_DIR" +npm --prefix "$DESKTOP_DIR" run tauri -- dev diff --git a/services/local-api/src/opsmineflow_api/app.py b/services/local-api/src/opsmineflow_api/app.py index 1388441..8c49052 100644 --- a/services/local-api/src/opsmineflow_api/app.py +++ b/services/local-api/src/opsmineflow_api/app.py @@ -13,6 +13,13 @@ from pathlib import Path from typing import Any +# A launcher secret belongs to the Rust parent, not to this long-running Python +# process or any diagnostics it may invoke. Capture the ownership nonce needed +# for the loopback health check, then remove both launcher values before loading +# the rest of the application. +_RUNTIME_NONCE = os.environ.pop("OPSMINEFLOW_RUNTIME_NONCE", "").strip() +os.environ.pop("OPSMINEFLOW_RUNTIME_SECRET", None) + from opsmineflow_drawio import build_drawio_xml from opsmineflow_mining import ( StandardEvent, @@ -33,6 +40,7 @@ from opsmineflow_mining.pipeline import metrics_to_dict from .activitywatch import import_activitywatch_local +from .child_process import sanitized_subprocess_environment from .recording import recording_manager from .storage import EventStore, default_store @@ -172,15 +180,13 @@ def create_api_snapshot(store: EventStore | None = None) -> dict[str, Any]: process_map = build_directly_follows_graph(events) store_diagnostics = active_store.diagnostics() automation_candidates = apply_automation_reviews(score_automation_candidates(events), active_store, events) + health = { + **create_runtime_health(), + "storage_mode": store_diagnostics["storage_mode"], + "event_count": store_diagnostics["event_count"], + } return { - "health": { - "status": "ok", - "bind": "127.0.0.1", - "local_only": True, - "llm_supported": False, - "storage_mode": store_diagnostics["storage_mode"], - "event_count": store_diagnostics["event_count"], - }, + "health": health, "events": [event_to_api_dict(event, settings) for event in events], "summary": metrics_to_dict(metrics), "app_switching": detect_app_switches(events), @@ -195,6 +201,20 @@ def create_api_snapshot(store: EventStore | None = None) -> dict[str, Any]: } +def create_runtime_health() -> dict[str, Any]: + """Return a constant-time loopback ownership payload for the Rust launcher.""" + + health: dict[str, Any] = { + "status": "ok", + "bind": "127.0.0.1", + "local_only": True, + "llm_supported": False, + } + if _RUNTIME_NONCE: + health["runtime"] = {"nonce": _RUNTIME_NONCE, "pid": os.getpid()} + return health + + def apply_automation_reviews( candidates: list[dict[str, object]], store: EventStore, @@ -948,7 +968,14 @@ def _dependency_status(name: str, command: list[str]) -> dict[str, str]: "remediation": f"Install {name} with ./scripts/install_mac.sh.", } try: - result = subprocess.run(command, check=False, capture_output=True, text=True, timeout=3) + result = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=3, + env=sanitized_subprocess_environment(), + ) except (OSError, subprocess.SubprocessError) as exc: return {"status": "error", "version": "", "remediation": str(exc)} version = (result.stdout or result.stderr).strip().splitlines()[0] if (result.stdout or result.stderr).strip() else "" @@ -986,6 +1013,7 @@ def _run_guardrail_script(script_name: str) -> dict[str, object]: capture_output=True, text=True, timeout=30, + env=sanitized_subprocess_environment(), ) except subprocess.TimeoutExpired: return {"status": "timeout", "command": f"./scripts/{script_name}", "output": "", "remediation": "Run the script manually for full output."} @@ -1036,6 +1064,10 @@ def _forbidden(message: str) -> Exception: def health() -> dict[str, Any]: return create_api_snapshot()["health"] + @app.get("/runtime/health") + def runtime_health() -> dict[str, Any]: + return create_runtime_health() + @app.get("/diagnostics") def diagnostics() -> dict[str, Any]: return create_diagnostics() diff --git a/services/local-api/src/opsmineflow_api/child_process.py b/services/local-api/src/opsmineflow_api/child_process.py new file mode 100644 index 0000000..92daaf0 --- /dev/null +++ b/services/local-api/src/opsmineflow_api/child_process.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os + + +_SAFE_SUBPROCESS_ENVIRONMENT_KEYS = ("PATH", "LANG", "LC_ALL", "TMPDIR") + + +def sanitized_subprocess_environment() -> dict[str, str]: + """Return the minimum ambient environment needed by local diagnostic tools.""" + + return { + key: value + for key in _SAFE_SUBPROCESS_ENVIRONMENT_KEYS + if (value := os.environ.get(key)) + } + + +def recording_agent_environment(token: str) -> dict[str, str]: + """Pass only the recording credential to the native recorder child process.""" + + return {"OPSMINEFLOW_RECORDING_TOKEN": token} diff --git a/services/local-api/src/opsmineflow_api/recording.py b/services/local-api/src/opsmineflow_api/recording.py index a52a19f..c20be3a 100644 --- a/services/local-api/src/opsmineflow_api/recording.py +++ b/services/local-api/src/opsmineflow_api/recording.py @@ -14,6 +14,8 @@ from opsmineflow_mining import build_native_app_event +from .child_process import recording_agent_environment as _recording_agent_environment +from .child_process import sanitized_subprocess_environment from .storage import EventStore, default_data_dir, default_store TOKEN_TTL_SECONDS = 12 * 60 * 60 @@ -142,8 +144,7 @@ def start(self, case_id: str, activity_label: str, consent: bool) -> dict[str, A self._token_issued_at = time.monotonic() self._seen_sequences = set() self._recent_ingest_times = [] - environment = dict(os.environ) - environment["OPSMINEFLOW_RECORDING_TOKEN"] = self._token + environment = _recording_agent_environment(self._token) api_port = os.environ.get("OPSMINEFLOW_API_PORT", "8765") self._process = subprocess.Popen( [ @@ -341,6 +342,7 @@ def _agent_version(self) -> str: capture_output=True, text=True, timeout=2, + env=sanitized_subprocess_environment(), ) except Exception: return "unknown" @@ -349,7 +351,5 @@ def _agent_version(self) -> str: self._agent_version_cache = version self._agent_version_mtime = mtime return version - - recording_manager = RecordingManager() atexit.register(recording_manager.shutdown) diff --git a/services/local-api/src/opsmineflow_api/server.py b/services/local-api/src/opsmineflow_api/server.py index 4ebc64c..3571e31 100644 --- a/services/local-api/src/opsmineflow_api/server.py +++ b/services/local-api/src/opsmineflow_api/server.py @@ -14,6 +14,7 @@ create_event_quality_report, create_export_artifact, create_import_preview, + create_runtime_health, import_activitywatch_into_store, import_path_into_store, run_diagnostic_checks, @@ -34,6 +35,9 @@ def do_OPTIONS(self) -> None: def do_GET(self) -> None: path = urlparse(self.path).path + if path == "/runtime/health": + self._send_json(create_runtime_health()) + return snapshot = create_api_snapshot() routes: dict[str, Any] = { "/health": snapshot["health"], diff --git a/services/local-api/tests/test_api_logic.py b/services/local-api/tests/test_api_logic.py index 00e3fac..11b9bdb 100644 --- a/services/local-api/tests/test_api_logic.py +++ b/services/local-api/tests/test_api_logic.py @@ -1,6 +1,9 @@ from __future__ import annotations +import http.client +import json import tempfile +import threading import unittest from dataclasses import replace from pathlib import Path @@ -14,12 +17,15 @@ create_event_quality_report, create_export_artifact, create_import_preview, + create_runtime_health, import_activitywatch_into_store, import_path_into_store, run_diagnostic_checks, save_export_artifact, ) -from opsmineflow_api.recording import RecordingManager, native_event_from_payload +from opsmineflow_api.child_process import sanitized_subprocess_environment +from opsmineflow_api.recording import RecordingManager, _recording_agent_environment, native_event_from_payload +from opsmineflow_api.server import LocalApiHandler from opsmineflow_api.storage import EventStore from opsmineflow_mining import load_events_from_csv @@ -206,6 +212,90 @@ def test_snapshot_contains_local_only_health_and_exports(self) -> None: self.assertIn("flowchart LR", snapshot["mermaid"]) self.assertIn(" None: + with patch("opsmineflow_api.app._RUNTIME_NONCE", "sidecar-owner-nonce"): + health = create_runtime_health() + + self.assertEqual(health["runtime"]["nonce"], "sidecar-owner-nonce") + self.assertIsInstance(health["runtime"]["pid"], int) + self.assertNotIn("storage_mode", health) + self.assertNotIn("event_count", health) + + def test_runtime_health_route_does_not_create_an_analysis_snapshot(self) -> None: + from http.server import ThreadingHTTPServer + + server = ThreadingHTTPServer(("127.0.0.1", 0), LocalApiHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=2) + try: + with patch("opsmineflow_api.server.create_api_snapshot", side_effect=AssertionError("must not analyze")): + connection.request("GET", "/runtime/health") + response = connection.getresponse() + payload = json.loads(response.read()) + finally: + connection.close() + server.shutdown() + thread.join(timeout=2) + server.server_close() + + self.assertEqual(response.status, 200) + self.assertEqual(payload["status"], "ok") + self.assertTrue(payload["local_only"]) + + def test_recording_agent_environment_does_not_inherit_runtime_credentials(self) -> None: + environment = _recording_agent_environment("recording-token") + + self.assertEqual(environment, {"OPSMINEFLOW_RECORDING_TOKEN": "recording-token"}) + + def test_diagnostic_subprocess_environment_does_not_inherit_runtime_credentials(self) -> None: + with patch.dict( + "os.environ", + { + "PATH": "/usr/bin:/bin", + "OPSMINEFLOW_RUNTIME_SECRET": "runtime-secret", + "OPSMINEFLOW_RUNTIME_NONCE": "runtime-nonce", + "PYTHONPATH": "/private/pythonpath", + }, + clear=True, + ): + environment = sanitized_subprocess_environment() + + self.assertEqual(environment, {"PATH": "/usr/bin:/bin"}) + + def test_recording_agent_version_probe_does_not_receive_runtime_credentials(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + agent_path = root / "probe-agent.sh" + probe_path = root / "environment.txt" + agent_path.write_text( + "#!/bin/bash\n" + "if [[ ${1:-} == --version ]]; then\n" + " printf '%s|%s|%s' \"${OPSMINEFLOW_RUNTIME_SECRET-}\" \"${OPSMINEFLOW_RUNTIME_NONCE-}\" \"${PYTHONPATH-}\" > \"$(dirname \"$0\")/environment.txt\"\n" + " echo 'opsmineflow-agent test'\n" + " exit 0\n" + "fi\n" + "exit 1\n", + encoding="utf-8", + ) + agent_path.chmod(0o755) + manager = RecordingManager(agent_path=agent_path, platform_name="Darwin") + with patch.dict( + "os.environ", + { + "PATH": "/usr/bin:/bin", + "OPSMINEFLOW_RUNTIME_SECRET": "runtime-secret", + "OPSMINEFLOW_RUNTIME_NONCE": "runtime-nonce", + "PYTHONPATH": "/private/pythonpath", + }, + clear=True, + ): + availability = manager.availability() + observed_environment = probe_path.read_text(encoding="utf-8") + + self.assertEqual(availability["agent_version"], "opsmineflow-agent test") + self.assertEqual(observed_environment, "||") + def test_event_quality_report_flags_and_approves_issues(self) -> None: events = load_events_from_csv("data/sample/sample_events.csv") unlabeled = replace(