diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index 1348dd94d..ca614c88b 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from "@playwright/test"; +const port = Number(process.env.COLOSSUS_PLAYWRIGHT_PORT ?? "1421"); + export default defineConfig({ testDir: "./tests/browser", fullyParallel: false, @@ -8,14 +10,14 @@ export default defineConfig({ workers: 1, reporter: process.env.CI === "true" ? "github" : "line", use: { - baseURL: "http://127.0.0.1:1421", + baseURL: `http://127.0.0.1:${port}`, screenshot: "only-on-failure", trace: "retain-on-failure", viewport: { width: 880, height: 640 }, }, webServer: { - command: "npm run dev -- --port 1421", - url: "http://127.0.0.1:1421/?fixture=operations-studio", + command: `npm run dev -- --port ${port}`, + url: `http://127.0.0.1:${port}/?fixture=operations-studio`, reuseExistingServer: process.env.CI !== "true", timeout: 120_000, }, diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index ff291c74b..9f03451a2 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -38,6 +38,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -329,6 +330,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -475,6 +500,12 @@ dependencies = [ "piper", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "brotli" version = "8.0.4" @@ -505,12 +536,28 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.2" @@ -729,6 +776,39 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "colossus-api" version = "0.11.6" @@ -833,6 +913,7 @@ dependencies = [ "colossus-native-browser", "colossus-native-credential-ui", "colossus-network", + "colossus-plugins", "colossus-ports", "colossus-sdk", "colossus-sidecar-protocol", @@ -929,6 +1010,27 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "colossus-journal-redb" +version = "0.11.6" +dependencies = [ + "base64 0.22.1", + "chacha20poly1305", + "colossus-contracts", + "colossus-ports", + "ed25519-dalek", + "fs4", + "getrandom 0.4.3", + "hex", + "keyring", + "redb", + "serde", + "serde_json", + "sha2", + "time", + "uuid", +] + [[package]] name = "colossus-linux-native" version = "0.11.6" @@ -1013,6 +1115,37 @@ dependencies = [ "url", ] +[[package]] +name = "colossus-plugins" +version = "0.11.6" +dependencies = [ + "base64 0.22.1", + "colossus-contracts", + "colossus-journal-redb", + "colossus-network", + "colossus-ports", + "colossus-windows-native", + "flate2", + "fs4", + "futures", + "hex", + "http", + "image", + "jsonschema", + "reqwest 0.12.28", + "rustix", + "serde", + "serde-saphyr", + "serde_json", + "sha2", + "sigstore-verify", + "tar", + "time", + "tokio", + "url", + "uuid", +] + [[package]] name = "colossus-ports" version = "0.11.6" @@ -1204,6 +1337,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -1281,6 +1426,33 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling" version = "0.23.0" @@ -1332,6 +1504,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.19", +] + [[package]] name = "der" version = "0.7.10" @@ -1339,6 +1542,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] @@ -1357,6 +1562,17 @@ dependencies = [ "rusticata-macros", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1572,6 +1788,31 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -1598,6 +1839,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "embed-resource" version = "3.0.11" @@ -1710,6 +1960,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1735,6 +1996,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1778,6 +2045,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -1788,6 +2061,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1842,6 +2126,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs4" version = "0.13.1" @@ -1852,6 +2146,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.33" @@ -2201,6 +2501,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2751,6 +3064,48 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -2767,6 +3122,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2838,6 +3223,33 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonschema" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5" +dependencies = [ + "ahash", + "base64 0.22.1", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna", + "itoa", + "num-cmp", + "num-traits", + "once_cell", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "reqwest 0.12.28", + "serde", + "serde_json", + "uuid-simd", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -3230,6 +3642,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -3590,6 +4008,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "opentelemetry" version = "0.32.0" @@ -3652,6 +4076,12 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -3930,6 +4360,15 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portable-pty" version = "0.9.0" @@ -4246,6 +4685,7 @@ version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", @@ -4431,6 +4871,20 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "referencing" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d" +dependencies = [ + "ahash", + "fluent-uri", + "once_cell", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.1" @@ -4468,6 +4922,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -4515,15 +4970,22 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4579,7 +5041,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -4626,6 +5088,7 @@ version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -4635,6 +5098,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.0" @@ -4645,15 +5120,43 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4668,6 +5171,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" + [[package]] name = "same-file" version = "1.0.6" @@ -4677,6 +5186,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -4904,6 +5422,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -5019,6 +5548,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -5081,12 +5621,209 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "sigstore-bundle" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94ba33ef96bafd16f5dfc954113c37a689dcf721816d11680667626b1ebb0496" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.19", +] + +[[package]] +name = "sigstore-crypto" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ecc48bf9facc710e772b3e7ab6a3ebc81c34be208a6ebf6c872d0ccbb8b52f" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.9.5", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.19", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f6fc5c295b5b9fa929e38acc53b29c8ed9ba3ee93e5fa87e7daa44072f0b26e" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.19", +] + +[[package]] +name = "sigstore-rekor" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece0d614b93c94e2ddab5c2142959396ce5a5f1d315435779e8ce0f9da250f5d" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest 0.13.4", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.19", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91dbe81d236075c568926b5540d86dbc0e272cd0c6cfc5523e35d5200e71cf0" +dependencies = [ + "base64 0.22.1", + "hex", + "jiff", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-tuf", + "sigstore-types", + "thiserror 2.0.19", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a04c841cdc52fc5da6284d6aa1fbf93f6b65d71437b2cc04751d117eac8aa33" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "jiff", + "rand 0.9.5", + "reqwest 0.13.4", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.19", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-tuf" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556727ed8c836bcfee6a151a3201e8605af30ef0e01dd9021a2fd43c3f5269f4" +dependencies = [ + "globset", + "hex", + "jiff", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2", + "sigstore-crypto", + "sigstore-types", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "sigstore-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a48661f5c7a4f7496de80865e5b258adac0bc514164e6c944b93b65ba5de334b" +dependencies = [ + "base64 0.22.1", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.19", +] + +[[package]] +name = "sigstore-verify" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4fed99a3609ebb236284b781674d8c45beea93bbc823ef2facd2261b3801c7" +dependencies = [ + "base64 0.22.1", + "cms", + "const-oid", + "hex", + "jiff", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.19", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -5323,7 +6060,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -5391,7 +6128,7 @@ dependencies = [ "heck 0.5.0", "http", "image", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -5621,7 +6358,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -5644,7 +6381,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -5848,6 +6585,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -5857,6 +6615,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -6343,6 +7102,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -6392,6 +7157,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -6416,6 +7192,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vswhom" version = "0.1.0" @@ -6627,6 +7409,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -7243,7 +8034,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -7290,6 +8081,20 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + [[package]] name = "x509-parser" version = "0.18.1" @@ -7308,6 +8113,17 @@ dependencies = [ "time", ] +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + [[package]] name = "xattr" version = "1.6.1" @@ -7471,6 +8287,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "serde", + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 8c7b91785..3bfd7ba8a 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -58,6 +58,7 @@ colossus-network = { version = "=0.11.6", path = "../../../crates/colossus-netwo colossus-native-credential-ui = { version = "=0.1.0", path = "../native-credential-ui" } colossus-native-browser = { version = "=0.1.0", path = "../native-browser" } colossus-ports = { version = "=0.11.6", path = "../../../crates/colossus-ports" } +colossus-plugins = { version = "=0.11.6", path = "../../../crates/colossus-plugins" } colossus-sdk = { version = "=0.11.6", path = "../../../crates/colossus-sdk", features = ["daemon", "keyring", "sidecar"] } colossus-sidecar-protocol = { version = "=0.11.6", path = "../../../crates/colossus-sidecar-protocol" } colossus-worker-protocol = { version = "=0.11.6", path = "../../../crates/colossus-worker-protocol" } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 8701293a8..02090461b 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -23,6 +23,8 @@ const COMMANDS: &[&str] = &[ "read_plugin_preview", "manage_plugin", "cancel_plugin_operation", + "outlook_companion_status", + "configure_outlook_companion", "desktop_release_channel", "desktop_release_metadata", "check_desktop_update", diff --git a/apps/desktop/src-tauri/capabilities/main-chat.json b/apps/desktop/src-tauri/capabilities/main-chat.json index 8ad9c0893..a2c997bb5 100644 --- a/apps/desktop/src-tauri/capabilities/main-chat.json +++ b/apps/desktop/src-tauri/capabilities/main-chat.json @@ -20,6 +20,8 @@ "allow-read-plugin-preview", "allow-manage-plugin", "allow-cancel-plugin-operation", + "allow-outlook-companion-status", + "allow-configure-outlook-companion", "allow-desktop-release-channel", "allow-desktop-release-metadata", "allow-export-diagnostics", diff --git a/apps/desktop/src-tauri/src/configuration_import.rs b/apps/desktop/src-tauri/src/configuration_import.rs index b381c2aba..1e7d16ed2 100644 --- a/apps/desktop/src-tauri/src/configuration_import.rs +++ b/apps/desktop/src-tauri/src/configuration_import.rs @@ -1349,6 +1349,7 @@ mod tests { access_profile: AccessProfileSetting::AllowAll, execution_boundary: ExecutionBoundarySetting::FullAccess, terminal_enabled: false, + outlook_companion_enabled: false, configuration: crate::managed_configuration::SpaceConfigurationSetting { accepted_global_revision: 1, ..crate::managed_configuration::SpaceConfigurationSetting::default() diff --git a/apps/desktop/src-tauri/src/desktop_settings.rs b/apps/desktop/src-tauri/src/desktop_settings.rs index 3c64d43f5..44a6a9760 100644 --- a/apps/desktop/src-tauri/src/desktop_settings.rs +++ b/apps/desktop/src-tauri/src/desktop_settings.rs @@ -157,6 +157,10 @@ pub(crate) struct WorkspaceProfile { pub(crate) access_profile: AccessProfileSetting, pub(crate) execution_boundary: ExecutionBoundarySetting, pub(crate) terminal_enabled: bool, + /// Explicit per-Workspace consent to attach a trusted Outlook plugin to the + /// logged-in Windows session. This is never inherited by a new Workspace. + #[serde(default)] + pub(crate) outlook_companion_enabled: bool, /// Sparse inherited configuration and pinned global catalog revisions. The legacy /// concrete fields above remain the selected-runtime compatibility projection. #[serde(default)] @@ -603,6 +607,7 @@ impl DesktopSettings { terminal_enabled: defaults .and_then(|d| d.terminal_enabled) .unwrap_or(self.terminal_enabled), + outlook_companion_enabled: false, configuration: SpaceConfigurationSetting { accepted_global_revision: self.global_configuration.revision, ..SpaceConfigurationSetting::default() @@ -691,6 +696,7 @@ impl DesktopSettings { access_profile: self.access_profile, execution_boundary: self.execution_boundary, terminal_enabled: self.terminal_enabled, + outlook_companion_enabled: false, configuration: SpaceConfigurationSetting { accepted_global_revision: self.global_configuration.revision, ..SpaceConfigurationSetting::default() @@ -2407,6 +2413,7 @@ mod tests { access_profile: settings.access_profile, execution_boundary: settings.execution_boundary, terminal_enabled: settings.terminal_enabled, + outlook_companion_enabled: false, configuration: SpaceConfigurationSetting::default(), }); let mut encoded = serde_json::to_value(settings).expect("settings"); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index a69fc7962..971fd0476 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -22,6 +22,8 @@ mod managed_configuration_commands; mod managed_diagnostics; mod managed_runtime; mod mcp_health; +#[cfg(windows)] +mod outlook_companion; mod plugin_adapter; mod plugin_commands; mod plugin_selection; @@ -87,7 +89,8 @@ use managed_diagnostics::{ managed_mcp_oauth_status, }; use plugin_commands::{ - cancel_plugin_operation, get_plugin_inventory, manage_plugin, read_plugin_preview, + cancel_plugin_operation, configure_outlook_companion, get_plugin_inventory, manage_plugin, + outlook_companion_status, read_plugin_preview, }; use plugin_selection::resolve_plugin_selection; use provider_catalog::{discover_managed_provider_models, get_provider_presets}; @@ -156,6 +159,8 @@ pub fn run() { .setup(|app| { status_bar::setup(app)?; browser::start_watchdog(app.handle().clone()); + #[cfg(windows)] + outlook_companion::start_watchdog(app.handle().clone()); terminal_commands::pane::start_watchdog(app.handle().clone()); Ok(()) }) @@ -187,6 +192,8 @@ pub fn run() { read_plugin_preview, manage_plugin, cancel_plugin_operation, + outlook_companion_status, + configure_outlook_companion, desktop_release_channel, desktop_release_metadata, check_desktop_update, diff --git a/apps/desktop/src-tauri/src/managed_configuration.rs b/apps/desktop/src-tauri/src/managed_configuration.rs index 35646a89a..bb409fb73 100644 --- a/apps/desktop/src-tauri/src/managed_configuration.rs +++ b/apps/desktop/src-tauri/src/managed_configuration.rs @@ -349,14 +349,7 @@ pub(crate) fn resolve_space_configuration( .defaults .revision(space.configuration.accepted_global_revision) .ok_or_else(configuration_error)?; - let mut field_overrides = defaults - .field_overrides - .iter() - .map(|field| (field.field_id.clone(), field.value.clone())) - .collect::>(); - for field in &space.configuration.field_overrides { - field_overrides.insert(field.field_id.clone(), field.value.clone()); - } + let field_overrides = merge_field_overrides(defaults, &space.configuration)?; let mut providers = resolve_catalog_values( &global.providers, &space.configuration.catalog_revisions, @@ -444,6 +437,58 @@ pub(crate) fn resolve_space_configuration( }) } +fn merge_field_overrides( + defaults: &DefaultOverridesSetting, + space: &SpaceConfigurationSetting, +) -> Result, CommandErrorDto> { + let mut fields = defaults + .field_overrides + .iter() + .map(|field| (field.field_id.clone(), field.value.clone())) + .collect::>(); + for field in &space.field_overrides { + let value = if field.field_id == "plugins.mcpServers" + && field.value.get(PLUGIN_SERVER_PATCH_MARKER) == Some(&Value::Bool(true)) + { + merge_plugin_server_overrides(fields.get(&field.field_id), &field.value)? + } else { + field.value.clone() + }; + fields.insert(field.field_id.clone(), value); + } + Ok(fields) +} + +pub(crate) const PLUGIN_SERVER_PATCH_MARKER: &str = "$colossusPatchV1"; + +fn merge_plugin_server_overrides( + inherited: Option<&Value>, + local: &Value, +) -> Result { + let mut servers = match inherited { + Some(value) => value.as_object().cloned().ok_or_else(configuration_error)?, + None => serde_json::Map::new(), + }; + let local = local.as_object().ok_or_else(configuration_error)?; + for (name, override_value) in local { + if name == PLUGIN_SERVER_PATCH_MARKER { + continue; + } + let mut server = match servers.get(name) { + Some(value) => value.as_object().cloned().ok_or_else(configuration_error)?, + None => serde_json::Map::new(), + }; + server.extend( + override_value + .as_object() + .ok_or_else(configuration_error)? + .clone(), + ); + servers.insert(name.clone(), Value::Object(server)); + } + Ok(Value::Object(servers)) +} + fn resolve_optional_catalog_value( entries: &[CatalogEntrySetting], references: &BTreeMap, @@ -1045,6 +1090,7 @@ mod tests { access_profile: AccessProfileSetting::AllowAll, execution_boundary: ExecutionBoundarySetting::FullAccess, terminal_enabled: false, + outlook_companion_enabled: false, configuration: SpaceConfigurationSetting::default(), } } @@ -1256,6 +1302,79 @@ mod tests { ); } + #[test] + fn plugin_server_override_keeps_unrelated_inherited_servers() { + let mut spaces = vec![space("one", provider("https://example.test/v1"))]; + let mut global = GlobalConfigurationSetting::default(); + initialize_catalog(&mut global, &mut spaces); + global.defaults.revisions[0] + .value + .field_overrides + .push(FieldOverrideSetting { + field_id: "plugins.mcpServers".into(), + value: serde_json::json!({ + "example/mail": {"enabled": false, "allowedTools": ["list_mail"]}, + "other/docs": {"enabled": true, "allowedTools": ["search"]} + }), + }); + spaces[0] + .configuration + .field_overrides + .push(FieldOverrideSetting { + field_id: "plugins.mcpServers".into(), + value: serde_json::json!({ + "$colossusPatchV1": true, + "example/mail": {"enabled": true} + }), + }); + let resolved = resolve_space_configuration(&global, &spaces[0]).expect("resolved"); + let servers = &resolved + .field_overrides + .iter() + .find(|field| field.field_id == "plugins.mcpServers") + .expect("plugin servers") + .value; + assert_eq!(servers["example/mail"]["enabled"], true); + assert_eq!( + servers["example/mail"]["allowedTools"], + serde_json::json!(["list_mail"]) + ); + assert_eq!( + servers["other/docs"]["allowedTools"], + serde_json::json!(["search"]) + ); + } + + #[test] + fn legacy_plugin_server_override_still_replaces_global_servers() { + let mut spaces = vec![space("one", provider("https://example.test/v1"))]; + let mut global = GlobalConfigurationSetting::default(); + initialize_catalog(&mut global, &mut spaces); + global.defaults.revisions[0] + .value + .field_overrides + .push(FieldOverrideSetting { + field_id: "plugins.mcpServers".into(), + value: serde_json::json!({"global/mail": {"enabled": true}}), + }); + spaces[0] + .configuration + .field_overrides + .push(FieldOverrideSetting { + field_id: "plugins.mcpServers".into(), + value: serde_json::json!({"local/docs": {"enabled": true}}), + }); + let resolved = resolve_space_configuration(&global, &spaces[0]).expect("resolved"); + let servers = &resolved + .field_overrides + .iter() + .find(|field| field.field_id == "plugins.mcpServers") + .expect("plugin servers") + .value; + assert!(servers.get("global/mail").is_none()); + assert_eq!(servers["local/docs"]["enabled"], true); + } + #[test] fn accepted_global_revision_controls_effective_defaults() { let mut global = GlobalConfigurationSetting { diff --git a/apps/desktop/src-tauri/src/managed_configuration_commands.rs b/apps/desktop/src-tauri/src/managed_configuration_commands.rs index 8907d3d7f..b52df018e 100644 --- a/apps/desktop/src-tauri/src/managed_configuration_commands.rs +++ b/apps/desktop/src-tauri/src/managed_configuration_commands.rs @@ -2253,6 +2253,7 @@ mod tests { access_profile: AccessProfileSetting::AllowAll, execution_boundary: ExecutionBoundarySetting::FullAccess, terminal_enabled: false, + outlook_companion_enabled: false, configuration: SpaceConfigurationSetting { accepted_global_revision: 1, ..SpaceConfigurationSetting::default() diff --git a/apps/desktop/src-tauri/src/managed_configuration_commands/catalog_deletion/tests.rs b/apps/desktop/src-tauri/src/managed_configuration_commands/catalog_deletion/tests.rs index 0947cecbc..6aaa66c54 100644 --- a/apps/desktop/src-tauri/src/managed_configuration_commands/catalog_deletion/tests.rs +++ b/apps/desktop/src-tauri/src/managed_configuration_commands/catalog_deletion/tests.rs @@ -103,6 +103,7 @@ fn workspace(name: &str, revision: u64) -> WorkspaceProfile { access_profile: AccessProfileSetting::Minimal, execution_boundary: ExecutionBoundarySetting::WorkspaceIsolated, terminal_enabled: false, + outlook_companion_enabled: false, configuration: SpaceConfigurationSetting { accepted_global_revision: revision, ..SpaceConfigurationSetting::default() diff --git a/apps/desktop/src-tauri/src/managed_configuration_commands/mcp_deletion/tests.rs b/apps/desktop/src-tauri/src/managed_configuration_commands/mcp_deletion/tests.rs index e2a514b1e..f3d301c79 100644 --- a/apps/desktop/src-tauri/src/managed_configuration_commands/mcp_deletion/tests.rs +++ b/apps/desktop/src-tauri/src/managed_configuration_commands/mcp_deletion/tests.rs @@ -76,6 +76,7 @@ fn workspace(name: &str, accepted_revision: u64) -> WorkspaceProfile { access_profile: AccessProfileSetting::Minimal, execution_boundary: ExecutionBoundarySetting::WorkspaceIsolated, terminal_enabled: false, + outlook_companion_enabled: false, configuration: SpaceConfigurationSetting { accepted_global_revision: accepted_revision, ..SpaceConfigurationSetting::default() diff --git a/apps/desktop/src-tauri/src/managed_runtime.rs b/apps/desktop/src-tauri/src/managed_runtime.rs index beea4a932..940265d14 100644 --- a/apps/desktop/src-tauri/src/managed_runtime.rs +++ b/apps/desktop/src-tauri/src/managed_runtime.rs @@ -1,3 +1,4 @@ +use colossus_contracts::HostSecret; use colossus_sdk::{ ApiMajor, ApiScope, AppPrivateInstanceDir, Colossus, CreateRunRequest, GetRunRequest, IdempotencyKey, InputContentPart, InstanceId, ListRunsRequest, ManagedAccessProfile, @@ -55,6 +56,8 @@ const ACTIVE_RUN_PAGE_SIZE: u32 = 100; const MAX_ACTIVE_RUN_PAGES: usize = 4_096; const CONFIGURATION_DRAIN_POLL_INTERVAL: Duration = Duration::from_millis(250); const CONFIGURATION_DRAIN_TIMEOUT: Duration = Duration::from_mins(5); +const OUTLOOK_SESSION_SERVER: &str = "outlook-session"; +const OUTLOOK_SESSION_CREDENTIAL: &str = "outlook-companion-session"; const PRIMARY_SCOPES: [&str; 7] = [ scopes::EXTENSIONS_READ, scopes::RUNS_EXECUTE, @@ -228,6 +231,8 @@ async fn start_after_operation_drain( Ok(()) } Err((error, failure_code)) => { + #[cfg(windows)] + state.stop_outlook_companion_for(space_id).await; state .clear_managed_lifecycle_for(space_id, lifecycle_generation) .await; @@ -682,6 +687,7 @@ async fn start_inner( ApiMajor::new(1).map_err(|error| classify_sdk(error, RuntimeFailureCodeDto::Internal))?, ) .map_err(|error| classify_sdk(error, RuntimeFailureCodeDto::Configuration))?; + let companion = start_outlook_companion(state, settings, space_id, &colossus_home).await?; let PreparedManagedBootstrap { bootstrap, worker_authentication, @@ -693,6 +699,7 @@ async fn start_inner( settings, &DesktopCredentials::for_settings(state, store) .map_err(|error| (error, RuntimeFailureCodeDto::Provider))?, + companion, ) .await?; let lifecycle = NativeSidecarLifecycle::new(bootstrap); @@ -766,12 +773,55 @@ struct PreparedManagedBootstrap { terminal_enabled: bool, } +struct CompanionBootstrap { + endpoint: String, + credential: HostSecret, +} + +#[cfg(windows)] +async fn start_outlook_companion( + state: &AppState, + settings: &DesktopSettings, + space_id: &str, + colossus_home: &Path, +) -> Result, (CommandErrorDto, RuntimeFailureCodeDto)> { + if settings + .space(space_id) + .is_some_and(|space| space.outlook_companion_enabled) + { + let (process, credential) = + crate::outlook_companion::OutlookCompanion::start(colossus_home) + .await + .map_err(|error| (error, RuntimeFailureCodeDto::Configuration))?; + let registration = CompanionBootstrap { + endpoint: process.endpoint.clone(), + credential, + }; + state.install_outlook_companion_for(space_id, process).await; + return Ok(Some(registration)); + } + Ok(None) +} + +#[cfg(not(windows))] +fn start_outlook_companion( + state: &AppState, + settings: &DesktopSettings, + space_id: &str, + colossus_home: &Path, +) -> std::future::Ready, (CommandErrorDto, RuntimeFailureCodeDto)>> +{ + let _ = (state, settings, space_id, colossus_home); + std::future::ready(Ok(None)) +} + async fn prepare_managed_bootstrap( workspace: &Path, workspace_identity: WorkspaceIdentity, store: &SettingsStore, settings: &DesktopSettings, credentials: &std::sync::Arc, + companion: Option, ) -> Result { let space = settings .selected_space_id @@ -818,6 +868,7 @@ async fn prepare_managed_bootstrap( approval_broker_grant, worker_bootstrap_secret.as_ref(), &paths, + companion, ) .map_err(|error| classify_sdk(error, RuntimeFailureCodeDto::Configuration))?; if let Some(identity) = settings.client_identity.as_ref() { @@ -849,8 +900,17 @@ fn managed_bootstrap( approval_broker_grant: SidecarApprovalBrokerGrant, worker_authentication: &[u8], paths: &ManagedBootstrapPaths<'_>, + companion: Option, ) -> Result { - let runtime = managed_runtime_config(resolved); + let mut runtime = managed_runtime_config(resolved); + let mut host_credentials = host_credentials; + if let Some(companion) = companion { + configure_outlook_companion(&mut runtime, &companion.endpoint)?; + host_credentials.push(SidecarHostCredential::new( + OUTLOOK_SESSION_CREDENTIAL, + companion.credential, + )?); + } let bootstrap = SidecarBootstrapConfig::new( workspace, runtime, @@ -874,6 +934,115 @@ fn managed_bootstrap( } } +fn configure_outlook_companion( + runtime: &mut ManagedRuntimeConfig, + endpoint: &str, +) -> Result<(), SdkError> { + if runtime + .mcp_servers + .iter() + .any(|server| server.name == OUTLOOK_SESSION_SERVER) + { + return Err(SdkError::InvalidConfiguration( + "Outlook companion server name conflicts with another MCP server", + )); + } + // An enabled portable stdio declaration still runs inside windows_job. The + // companion replaces that one connection for this Workspace only. + if let Some(field) = runtime + .field_overrides + .iter_mut() + .find(|field| field.field_id == "plugins.mcpServers") + && let Some(overlay) = field.value.as_object_mut() + && let Some(mail) = overlay.get_mut("outlook-classic/mail") + && let Some(mail) = mail.as_object_mut() + { + mail.insert("enabled".into(), serde_json::Value::Bool(false)); + } + runtime.mcp_servers.push(ManagedMcpServerConfig { + name: OUTLOOK_SESSION_SERVER.into(), + transport: ManagedMcpTransport::StreamableHttp, + command: None, + args: Vec::new(), + working_directory: None, + environment_credentials: BTreeMap::new(), + url: Some(endpoint.into()), + headers: BTreeMap::new(), + credential_headers: BTreeMap::from([( + "Authorization".into(), + ManagedMcpCredentialHeader { + scheme: Some("Bearer".into()), + credential_id: OUTLOOK_SESSION_CREDENTIAL.into(), + }, + )]), + allow_stateless: true, + oauth: None, + allowed_tools: [ + "get_status", + "list_stores", + "list_folders", + "get_mail_folders", + "list_messages", + "search_messages", + "get_message", + "list_attachments", + "mark_message_read", + "move_message", + "archive_message", + "delete_message", + "create_draft", + "update_draft", + ] + .into_iter() + .map(str::to_owned) + .collect(), + research_tools: Vec::new(), + timeout_ms: Some(30_000), + max_output_bytes: Some(65_536), + }); + Ok(()) +} + +#[cfg(test)] +mod outlook_companion_tests { + use super::*; + use serde_json::json; + + #[test] + fn companion_uses_exact_http_endpoint_and_disables_portable_stdio() { + let mut runtime = ManagedRuntimeConfig::echo(ManagedAccessProfile::Development); + runtime.field_overrides.push(ManagedFieldOverride { + field_id: "plugins.mcpServers".into(), + value: json!({ + "outlook-classic/mail": {"enabled": true, "allowedTools": ["*"]}, + "other/plugin": {"enabled": true} + }), + }); + configure_outlook_companion(&mut runtime, "http://127.0.0.1:64123/mcp") + .expect("configure companion"); + let server = runtime.mcp_servers.last().expect("companion server"); + assert_eq!(server.name, OUTLOOK_SESSION_SERVER); + assert_eq!(server.url.as_deref(), Some("http://127.0.0.1:64123/mcp")); + assert_eq!(server.transport, ManagedMcpTransport::StreamableHttp); + assert!(server.allow_stateless); + assert_eq!( + server.credential_headers["Authorization"].scheme.as_deref(), + Some("Bearer") + ); + assert_eq!(server.allowed_tools.len(), 14); + assert!(!server.allowed_tools.iter().any(|tool| tool == "*")); + assert_eq!( + runtime.field_overrides[0].value["outlook-classic/mail"]["enabled"], + false + ); + assert_eq!( + runtime.field_overrides[0].value["other/plugin"]["enabled"], + true + ); + runtime.validate().expect("valid managed transport"); + } +} + #[allow(clippy::too_many_lines)] pub(crate) fn managed_runtime_config( resolved: &ResolvedSpaceConfiguration, diff --git a/apps/desktop/src-tauri/src/managed_runtime/credential_acceptance.rs b/apps/desktop/src-tauri/src/managed_runtime/credential_acceptance.rs index 38706d43f..b90c793fb 100644 --- a/apps/desktop/src-tauri/src/managed_runtime/credential_acceptance.rs +++ b/apps/desktop/src-tauri/src/managed_runtime/credential_acceptance.rs @@ -183,6 +183,7 @@ async fn native_backend_process_child() { codex_auth: None, colossus_home: &home, }, + None, ) .unwrap(); let identity = settings.load().unwrap().client_identity.unwrap(); diff --git a/apps/desktop/src-tauri/src/outlook_companion.rs b/apps/desktop/src-tauri/src/outlook_companion.rs new file mode 100644 index 000000000..9a35afa78 --- /dev/null +++ b/apps/desktop/src-tauri/src/outlook_companion.rs @@ -0,0 +1,230 @@ +//! Native-owned bridge to classic Outlook in the logged-in Windows session. +//! The ordinary plugin MCP process continues to use the configured sandbox. + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use colossus_contracts::{HostSecret, PluginMcpTransport}; +use colossus_plugins::{PluginSnapshotLease, PluginStore}; +use serde::Deserialize; +use std::{os::windows::process::CommandExt as _, path::Path, process::Stdio, time::Duration}; +use tauri::{AppHandle, Manager as _}; +use tokio::{ + io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}, + process::{Child, ChildStdin, Command}, + time::timeout, +}; +use url::Url; +use zeroize::{Zeroize as _, Zeroizing}; + +use crate::dto::CommandErrorDto; + +const PLUGIN_NAME: &str = "outlook-classic"; +const COMPONENT_ID: &str = "outlook-classic/mail"; +const SIGNER: &str = "https://token.actions.githubusercontent.com|https://github.com/obscuritylabs/colossus-plugins/.github/workflows/plugins.yml@refs/heads/main"; +const START_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Handshake { + endpoint: String, + process_id: u32, +} + +pub(crate) struct OutlookCompanion { + child: Child, + stdin: ChildStdin, + // A plugin update or GC cannot replace this verified executable while it runs. + _lease: PluginSnapshotLease, + pub(crate) endpoint: String, + pub(crate) digest: String, +} + +impl OutlookCompanion { + pub(crate) async fn start(home: &Path) -> Result<(Self, HostSecret), CommandErrorDto> { + let home = home.to_path_buf(); + let (executable, digest, lease) = tokio::task::spawn_blocking(move || { + let store = PluginStore::new(home).map_err(|_| unavailable())?; + let (records, lease) = store + .snapshot_with_lease(&[PLUGIN_NAME.into()], &[]) + .map_err(|_| unavailable())?; + let plugin = records + .into_iter() + .find(|record| record.installation.manifest.name == PLUGIN_NAME) + .ok_or_else(unavailable)?; + if !plugin.installation.trust.trusted + || plugin.installation.trust.profile.as_deref() != Some("obscuritylabs") + || plugin.installation.trust.method != "sigstore-keyless" + || plugin.installation.trust.signer.as_deref() != Some(SIGNER) + || plugin.installation.manifest.repository.as_deref() + != Some("https://github.com/obscuritylabs/colossus-plugins") + || !plugin.mcp_servers.iter().any(|server| { + server.id == COMPONENT_ID + && server.transport == PluginMcpTransport::Stdio + && server.command.as_deref() == Some("./bin/outlook-classic-mcp.exe") + }) + { + return Err(unavailable()); + } + let root = + std::fs::canonicalize(&plugin.installation.root).map_err(|_| unavailable())?; + let executable = std::fs::canonicalize(root.join("bin/outlook-classic-mcp.exe")) + .map_err(|_| unavailable())?; + if !executable.starts_with(&root) || !executable.is_file() { + return Err(unavailable()); + } + Ok((executable, plugin.installation.digest, lease)) + }) + .await + .map_err(|_| unavailable())??; + + let mut random = [0_u8; 32]; + getrandom::fill(&mut random).map_err(|_| unavailable())?; + let token = Zeroizing::new(URL_SAFE_NO_PAD.encode(random)); + random.zeroize(); + let credential = HostSecret::new(token.to_string()).map_err(|_| unavailable())?; + let mut command = Command::new(executable); + command + .arg("--http-companion") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + command.as_std_mut().creation_flags(0x0800_0000); // CREATE_NO_WINDOW + let mut child = command.spawn().map_err(|_| unavailable())?; + let mut stdin = child.stdin.take().ok_or_else(unavailable)?; + let stdout = child.stdout.take().ok_or_else(unavailable)?; + stdin + .write_all(token.as_bytes()) + .await + .map_err(|_| unavailable())?; + stdin.write_all(b"\n").await.map_err(|_| unavailable())?; + stdin.flush().await.map_err(|_| unavailable())?; + let mut line = String::new(); + let mut reader = BufReader::new(stdout.take(513)); + let bytes = timeout(START_TIMEOUT, reader.read_line(&mut line)) + .await + .map_err(|_| unavailable())? + .map_err(|_| unavailable())?; + if bytes == 0 || bytes > 512 { + return Err(unavailable()); + } + let handshake: Handshake = serde_json::from_str(&line).map_err(|_| unavailable())?; + if child.id() != Some(handshake.process_id) || !valid_endpoint(&handshake.endpoint) { + return Err(unavailable()); + } + Ok(( + Self { + child, + stdin, + _lease: lease, + endpoint: handshake.endpoint, + digest, + }, + credential, + )) + } + + pub(crate) async fn stop(mut self) { + drop(self.stdin); + if timeout(Duration::from_secs(3), self.child.wait()) + .await + .is_err() + { + let _ = self.child.kill().await; + } + } + + pub(crate) fn active_digest(&mut self) -> Option { + self.child + .try_wait() + .ok() + .filter(Option::is_none) + .map(|_| self.digest.clone()) + } +} + +fn valid_endpoint(endpoint: &str) -> bool { + Url::parse(endpoint).is_ok_and(|url| { + url.scheme() == "http" + && url.host_str() == Some("127.0.0.1") + && url.port().is_some_and(|port| port != 0) + && url.path() == "/mcp" + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + }) +} + +fn unavailable() -> CommandErrorDto { + CommandErrorDto::local_sanitized( + "outlook_companion_unavailable", + "Install and activate a signed Obscurity Labs Outlook Classic plugin with companion support, then restart this Workspace.", + false, + ) +} + +/// Revoke a helper when a CLI or another Workspace activates a different +/// digest. Desktop management revokes immediately; this covers external changes. +pub(crate) fn start_watchdog(app: AppHandle) { + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(15)); + loop { + interval.tick().await; + let state = app.state::(); + reconcile_active_digest(&state).await; + } + }); +} + +pub(crate) async fn reconcile_active_digest(state: &crate::state::AppState) { + let snapshots = state.outlook_companion_snapshots().await; + if snapshots.is_empty() { + return; + } + let active = if let Ok(store) = crate::desktop_commands::settings_store() { + if let Ok(home) = store.home_root() { + let home = home.to_path_buf(); + tokio::task::spawn_blocking(move || { + PluginStore::new(home) + .and_then(|store| store.active(PLUGIN_NAME)) + .ok() + .flatten() + .map(|installation| installation.digest) + }) + .await + .ok() + .flatten() + } else { + None + } + } else { + None + }; + for (space_id, digest) in snapshots { + if active.as_deref() != Some(&digest) { + state + .stop_outlook_companion_if_digest(&space_id, &digest) + .await; + } + } +} + +#[cfg(test)] +mod tests { + use super::valid_endpoint; + + #[test] + fn endpoint_accepts_only_one_ipv4_loopback_mcp_route() { + assert!(valid_endpoint("http://127.0.0.1:64123/mcp")); + for value in [ + "http://localhost:64123/mcp", + "http://127.0.0.1/mcp", + "http://127.0.0.1:64123/other", + "http://127.0.0.1:64123/mcp?x=1", + "http://x@127.0.0.1:64123/mcp", + "https://127.0.0.1:64123/mcp", + ] { + assert!(!valid_endpoint(value), "{value}"); + } + } +} diff --git a/apps/desktop/src-tauri/src/plugin_commands.rs b/apps/desktop/src-tauri/src/plugin_commands.rs index 6dadc0131..774e06045 100644 --- a/apps/desktop/src-tauri/src/plugin_commands.rs +++ b/apps/desktop/src-tauri/src/plugin_commands.rs @@ -1,18 +1,161 @@ //! Native-owned target binding, file dialogs, and policy approval for plugin management. use colossus_worker_protocol::PluginManagementRequest; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tauri::{AppHandle, State}; use tauri_plugin_dialog::{DialogExt as _, MessageDialogButtons, MessageDialogKind}; +#[cfg(windows)] +use crate::managed_configuration::FieldOverrideSetting; use crate::plugin_adapter::{self, PluginInventoryDto, PluginPreviewInput, PluginPreviewKind}; use crate::{ commands::{target, unary_slot}, + desktop_commands::settings_store, dto::CommandErrorDto, managed_diagnostics::worker_for, state::{AppState, TargetConsentContext}, }; +#[cfg(windows)] +use crate::{ + desktop_commands::connect_guard, managed_configuration_commands::persist_and_restart, + managed_runtime, +}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OutlookCompanionStatusDto { + supported: bool, + enabled: bool, + active_digest: Option, +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn outlook_companion_status( + state: State<'_, AppState>, + space_id: String, +) -> Result { + let settings = settings_store()?.load()?; + let space = settings + .space(&space_id) + .ok_or_else(|| CommandErrorDto::invalid("spaceId", "The Workspace is unknown."))?; + #[cfg(windows)] + let active_digest = state.outlook_companion_digest_for(&space_id).await; + #[cfg(not(windows))] + let active_digest = { + let _ = state; + None + }; + Ok(OutlookCompanionStatusDto { + supported: cfg!(windows), + enabled: space.outlook_companion_enabled, + active_digest, + }) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn configure_outlook_companion( + state: State<'_, AppState>, + space_id: String, + enabled: bool, +) -> Result { + #[cfg(not(windows))] + { + let _ = (state, space_id, enabled); + Err(CommandErrorDto::invalid( + "spaceId", + "Classic Outlook integration requires Windows Desktop.", + )) + } + #[cfg(windows)] + { + let _guard = connect_guard(&state)?; + let store = settings_store()?; + let mut settings = store.load()?; + if settings.selected_space_id.as_deref() != Some(&space_id) { + return Err(CommandErrorDto::invalid( + "spaceId", + "Select this Workspace before changing its Outlook connection.", + )); + } + let previous = settings.clone(); + let space = settings + .space(&space_id) + .ok_or_else(|| CommandErrorDto::invalid("spaceId", "The Workspace is unknown."))?; + if space.archived { + return Err(CommandErrorDto::invalid( + "spaceId", + "Restore this Workspace first.", + )); + } + let space = settings + .spaces + .iter_mut() + .find(|space| space.id == space_id) + .expect("space checked above"); + let portable_changed = + disable_portable_outlook_connection(&mut space.configuration.field_overrides)?; + if space.outlook_companion_enabled != enabled || portable_changed { + if enabled { + // Verify both the active signed digest and its companion handshake + // before persisting a setting that would fail every future startup. + let (probe, _) = + crate::outlook_companion::OutlookCompanion::start(store.home_root()?).await?; + probe.stop().await; + } + space.outlook_companion_enabled = enabled; + let drain = + managed_runtime::drain_active_runs_for_configuration(&state, &space_id).await?; + persist_and_restart(&state, &store, &mut settings, previous, &space_id).await?; + drop(drain); + } + let active_digest = state.outlook_companion_digest_for(&space_id).await; + Ok(OutlookCompanionStatusDto { + supported: true, + enabled, + active_digest, + }) + } +} + +#[cfg(windows)] +fn disable_portable_outlook_connection( + overrides: &mut Vec, +) -> Result { + let mut servers = overrides + .iter() + .find(|field| field.field_id == "plugins.mcpServers") + .map_or_else( + || json!({ crate::managed_configuration::PLUGIN_SERVER_PATCH_MARKER: true }), + |field| field.value.clone(), + ); + let servers = servers.as_object_mut().ok_or_else(|| { + CommandErrorDto::invalid("plugins.mcpServers", "The plugin connections are invalid.") + })?; + let server = servers + .entry("outlook-classic/mail") + .or_insert_with(|| json!({})); + let server = server.as_object_mut().ok_or_else(|| { + CommandErrorDto::invalid("plugins.mcpServers", "The Outlook connection is invalid.") + })?; + server.insert("enabled".into(), Value::Bool(false)); + let value = Value::Object(servers.clone()); + if let Some(existing) = overrides + .iter_mut() + .find(|field| field.field_id == "plugins.mcpServers") + { + if existing.value == value { + return Ok(false); + } + existing.value = value; + } else { + overrides.push(FieldOverrideSetting { + field_id: "plugins.mcpServers".into(), + value, + }); + } + Ok(true) +} #[tauri::command(rename_all = "camelCase")] pub(crate) async fn get_plugin_inventory( @@ -156,7 +299,28 @@ pub(crate) async fn manage_plugin( else { return Ok(json!({"cancelled": true})); }; - plugin_adapter::manage(&worker, request, cancellation, |prompt| { + #[cfg(windows)] + let revokes_outlook = matches!( + &request, + PluginManagementRequest::Enable { name, .. } + | PluginManagementRequest::Disable { name } + | PluginManagementRequest::Update { name, .. } + | PluginManagementRequest::Uninstall { name, .. } + if name == "outlook-classic" + ); + #[cfg(windows)] + let force_revoke_on_success = match &request { + PluginManagementRequest::Disable { name } if name == "outlook-classic" => true, + PluginManagementRequest::Uninstall { name, digest, .. } if name == "outlook-classic" => { + state + .outlook_companion_snapshots() + .await + .iter() + .any(|(_, active_digest)| active_digest == digest) + } + _ => false, + }; + let result = plugin_adapter::manage(&worker, request, cancellation, |prompt| { let app = app.clone(); let target_id = target_id.clone(); async move { @@ -168,7 +332,30 @@ pub(crate) async fn manage_plugin( .buttons(MessageDialogButtons::OkCancelCustom(approve.clone(), deny)).blocking_show()).await.ok()?; accepted.then_some(prompt.choices[0].clone()) } - }).await.map_err(operation_error) + }).await; + #[cfg(windows)] + if revokes_outlook { + match &result { + Ok(value) + if force_revoke_on_success + && value.get("cancelled").and_then(Value::as_bool) != Some(true) => + { + // A disable, or removal of the running digest, invalidates its + // session even if another process re-enables it immediately. + state.stop_all_outlook_companions().await; + } + Ok(_) | Err(colossus_worker_protocol::WorkerControlError::Remote(_)) => { + // Updates can leave the old digest active, and a failed operation + // can still have changed it. Keep a healthy helper when it matches. + crate::outlook_companion::reconcile_active_digest(&state).await; + } + Err(_) => { + // A lost worker response leaves the operation outcome unknown. + state.stop_all_outlook_companions().await; + } + } + } + result.map_err(operation_error) } #[tauri::command(rename_all = "camelCase")] @@ -249,3 +436,35 @@ fn operation_error(error: colossus_worker_protocol::WorkerControlError) -> Comma _ => CommandErrorDto { code: "plugin_operation_unknown".into(), message: "The plugin operation lost contact with the runtime. Refresh the inventory before retrying; a change may have committed.".into(), retryable: false, outcome_unknown: true, violations: Vec::new() }, } } + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + fn disconnect_persists_portable_stdio_disabled_without_copying_other_servers() { + let mut fields = vec![FieldOverrideSetting { + field_id: "plugins.mcpServers".into(), + value: json!({ + "outlook-classic/mail": {"enabled": true, "allowedTools": ["get_status"]}, + "local/docs": {"enabled": true} + }), + }]; + assert!(disable_portable_outlook_connection(&mut fields).expect("disable")); + assert_eq!(fields[0].value["outlook-classic/mail"]["enabled"], false); + assert_eq!( + fields[0].value["outlook-classic/mail"]["allowedTools"], + json!(["get_status"]) + ); + assert_eq!(fields[0].value["local/docs"]["enabled"], true); + assert!(!disable_portable_outlook_connection(&mut fields).expect("idempotent")); + } + + #[test] + fn disconnect_without_local_override_uses_a_server_patch() { + let mut fields = Vec::new(); + assert!(disable_portable_outlook_connection(&mut fields).expect("disable")); + assert_eq!(fields[0].value["$colossusPatchV1"], true); + assert_eq!(fields[0].value["outlook-classic/mail"]["enabled"], false); + } +} diff --git a/apps/desktop/src-tauri/src/provider_catalog.rs b/apps/desktop/src-tauri/src/provider_catalog.rs index c6cdd4210..bd75166cc 100644 --- a/apps/desktop/src-tauri/src/provider_catalog.rs +++ b/apps/desktop/src-tauri/src/provider_catalog.rs @@ -581,6 +581,7 @@ mod tests { access_profile: AccessProfileSetting::Minimal, execution_boundary: ExecutionBoundarySetting::OfflineIsolated, terminal_enabled: false, + outlook_companion_enabled: false, configuration: crate::managed_configuration::SpaceConfigurationSetting::default(), }); assert_eq!( diff --git a/apps/desktop/src-tauri/src/state.rs b/apps/desktop/src-tauri/src/state.rs index f2e00b509..812639bdc 100644 --- a/apps/desktop/src-tauri/src/state.rs +++ b/apps/desktop/src-tauri/src/state.rs @@ -257,6 +257,8 @@ pub(crate) struct AppState { pub(crate) mcp_health_history: StdMutex>, pub(crate) plugin_operations: StdMutex)>>, + #[cfg(windows)] + outlook_companions: Mutex>, targets: RwLock>, selected_target_id: RwLock>, selection_epoch: AtomicU64, @@ -422,6 +424,8 @@ impl Default for AppState { mcp_health_history: StdMutex::new(std::collections::VecDeque::new()), selected_target_id: RwLock::new(None), plugin_operations: StdMutex::new(HashMap::new()), + #[cfg(windows)] + outlook_companions: Mutex::new(HashMap::new()), selection_epoch: AtomicU64::new(0), selection_updates, run_targets: RwLock::new(HashMap::new()), @@ -557,7 +561,85 @@ impl AppState { } pub(crate) async fn remove_target(&self, target_id: &str) -> Option { - self.targets.write().await.remove(target_id) + let target = self.targets.write().await.remove(target_id); + #[cfg(windows)] + self.stop_outlook_companion_for(target_id).await; + target + } + + #[cfg(windows)] + pub(crate) async fn install_outlook_companion_for( + &self, + space_id: &str, + companion: crate::outlook_companion::OutlookCompanion, + ) { + let previous = self + .outlook_companions + .lock() + .await + .insert(space_id.to_owned(), companion); + if let Some(previous) = previous { + previous.stop().await; + } + } + + #[cfg(windows)] + pub(crate) async fn stop_outlook_companion_for(&self, space_id: &str) { + let companion = self.outlook_companions.lock().await.remove(space_id); + if let Some(companion) = companion { + companion.stop().await; + } + } + + #[cfg(windows)] + pub(crate) async fn stop_all_outlook_companions(&self) { + let companions = self + .outlook_companions + .lock() + .await + .drain() + .map(|(_, companion)| companion) + .collect::>(); + for companion in companions { + companion.stop().await; + } + } + + #[cfg(windows)] + pub(crate) async fn outlook_companion_digest_for(&self, space_id: &str) -> Option { + self.outlook_companions + .lock() + .await + .get_mut(space_id) + .and_then(crate::outlook_companion::OutlookCompanion::active_digest) + } + + #[cfg(windows)] + pub(crate) async fn outlook_companion_snapshots(&self) -> Vec<(String, String)> { + self.outlook_companions + .lock() + .await + .iter() + .map(|(space_id, companion)| (space_id.clone(), companion.digest.clone())) + .collect() + } + + #[cfg(windows)] + pub(crate) async fn stop_outlook_companion_if_digest(&self, space_id: &str, digest: &str) { + let companion = { + let mut current = self.outlook_companions.lock().await; + if current + .get(space_id) + .is_some_and(|value| value.digest == digest) + { + current.remove(space_id) + } else { + None + } + }; + if let Some(companion) = companion { + companion.stop().await; + } } pub(crate) async fn connected(&self, target_id: &str) -> bool { @@ -1207,6 +1289,8 @@ impl AppState { } pub(crate) async fn close_all(&self) { + #[cfg(windows)] + self.stop_all_outlook_companions().await; { // Terminal processes may hold worker IPC (notably the bundled TUI). // Revoke their document authority and tear down their process trees diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 2d10c50b7..a00e87b1a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -999,7 +999,7 @@ export default function App() { const [showOnboarding, setShowOnboarding] = useState(false); const [surface, setSurface] = useState("work"); const [settingsStartTab, setSettingsStartTab] = useState< - "runtime" | "providers" | "terminal" + "runtime" | "providers" | "plugins" | "terminal" >("runtime"); const [workNavigationOpen, setWorkNavigationOpen] = useState(false); const [workspaceFileOpenRequest, setWorkspaceFileOpenRequest] = @@ -5482,6 +5482,10 @@ export default function App() { ) : ( { + setSettingsStartTab("plugins"); + setSurface("settings"); + }} onReturnToWork={() => selectSurface("work")} pluginSelections={pluginSelections} onUsePluginSkill={(id) => { diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 41c780272..db6e9bab3 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -1,6 +1,10 @@ import { Channel, invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import type { PluginInventory, PluginRequest } from "./plugins"; +import type { + OutlookCompanionStatus, + PluginInventory, + PluginRequest, +} from "./plugins"; import { readNativeDialogAppearance } from "./theme/appearance"; import type { ProviderPreset, ProviderCatalogModel } from "./providerCatalog"; import type { GitStatus, GitCommitPage, GitCommitDetails } from "./git"; @@ -157,6 +161,17 @@ export function discoverManagedProviderModels( export function getPluginInventory(targetId: string): Promise { return call("get_plugin_inventory", { targetId }); } +export function getOutlookCompanionStatus( + spaceId: string, +): Promise { + return call("outlook_companion_status", { spaceId }); +} +export function configureOutlookCompanion( + spaceId: string, + enabled: boolean, +): Promise { + return call("configure_outlook_companion", { spaceId, enabled }); +} export function resolvePluginSelection( targetId: string, prompt: string, diff --git a/apps/desktop/src/components/ManagedSettingsPane.tsx b/apps/desktop/src/components/ManagedSettingsPane.tsx index 3a8077eb8..c934b9243 100644 --- a/apps/desktop/src/components/ManagedSettingsPane.tsx +++ b/apps/desktop/src/components/ManagedSettingsPane.tsx @@ -229,7 +229,8 @@ type SpaceTab = | "effective"; interface ManagedSettingsPaneProps { - initialSpaceTab?: "runtime" | "providers" | "terminal" | undefined; + initialSpaceTab?: + "runtime" | "providers" | "plugins" | "terminal" | undefined; desktop: DesktopStatus; connecting: boolean; updateChecking: boolean; diff --git a/apps/desktop/src/components/OperationsSurface.tsx b/apps/desktop/src/components/OperationsSurface.tsx index b2067dc99..b12d43634 100644 --- a/apps/desktop/src/components/OperationsSurface.tsx +++ b/apps/desktop/src/components/OperationsSurface.tsx @@ -32,7 +32,9 @@ import { PluginsSurface } from "./PluginsSurface"; import type { WorkspaceSurface } from "./ProductRail"; interface OperationsSurfaceProps { - initialSettingsTab?: "runtime" | "providers" | "terminal" | undefined; + initialSettingsTab?: + "runtime" | "providers" | "plugins" | "terminal" | undefined; + onConfigurePluginConnection?: () => void; pluginSelections?: readonly string[]; onUsePluginSkill?: (id: string) => void; surface: Exclude; @@ -1016,6 +1018,8 @@ export function OperationsSurface(props: OperationsSurfaceProps) { void) | undefined; +}) { + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [check, setCheck] = useState(""); + + useEffect(() => { + let current = true; + void getOutlookCompanionStatus(spaceId) + .then((value) => { + if (current) setStatus(value); + }) + .catch((reason: unknown) => { + if (current) + setError( + reason instanceof Error + ? reason.message + : "Could not read Outlook status.", + ); + }); + return () => { + current = false; + }; + }, [spaceId]); + + if (!status) return error ?

{error}

: null; + if (!status.supported) return null; + + async function toggle() { + if (!status || busy) return; + setBusy(true); + setError(""); + setCheck(""); + try { + const result = await configureOutlookCompanion(spaceId, !status.enabled); + setStatus(result); + onChanged?.(); + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : "Could not change the Outlook session connection.", + ); + } finally { + setBusy(false); + } + } + + async function testConnection() { + if (!status?.activeDigest || busy) return; + setBusy(true); + setError(""); + setCheck(""); + try { + const result = await diagnoseManagedMcpServer(spaceId, "outlook-session"); + setCheck( + result.healthy + ? `${result.tools.length} allowlisted Outlook tools discovered. Outlook attachment is checked when a tool runs.` + : (result.message ?? "Outlook session connection failed."), + ); + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : "Could not test Outlook connection.", + ); + } finally { + setBusy(false); + } + } + + return ( +
+
Classic Outlook session
+

+ Run the signed Outlook plugin in your Windows sign-in session. This + connection can read and change mail through its exact listed tools; + Colossus still reviews and audits tool calls. +

+ + + {status.enabled && ( +

+ {status.activeDigest + ? `Helper running from verified digest ${status.activeDigest}.` + : "Enabled; start this Workspace to connect."} +

+ )} + {check &&

{check}

} + {error &&

{error}

} +
+ ); +} diff --git a/apps/desktop/src/components/PluginDetail.tsx b/apps/desktop/src/components/PluginDetail.tsx index 19d560eba..645c4e49b 100644 --- a/apps/desktop/src/components/PluginDetail.tsx +++ b/apps/desktop/src/components/PluginDetail.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { readPluginPreview } from "../api"; import type { PluginEntry, PluginResource, PluginSkill } from "../plugins"; import { PluginMcpControls } from "./PluginMcpControls"; +import { OutlookCompanionControls } from "./OutlookCompanionControls"; import { PluginIcon } from "./PluginIcon"; import type { PluginAction } from "./PluginOperationForm"; @@ -14,6 +15,9 @@ function failure(error: unknown): string { export function PluginDetail({ plugin, targetId, + spaceId, + onConfigureConnection, + onConnectionChanged, managementAvailable, selections, onUseSkill, @@ -22,6 +26,9 @@ export function PluginDetail({ }: { plugin: PluginEntry; targetId: string; + spaceId: string | null; + onConfigureConnection?: (() => void) | undefined; + onConnectionChanged?: (() => void) | undefined; managementAvailable: boolean; selections: readonly string[]; onUseSkill: ((id: string) => void) | undefined; @@ -127,11 +134,17 @@ export function PluginDetail({ {server.id} · {server.transport} ·{" "} {server.status}

- {managementAvailable && ( + {managementAvailable && spaceId && ( )) )} + {managementAvailable && + spaceId && + plugin.manifest.name === "outlook-classic" && ( + + )} + {managementAvailable && + plugin.mcp_servers.length > 0 && + onConfigureConnection && ( + + )}

Enable individual MCP servers explicitly in plugin settings. Credential configuration does not enable them. diff --git a/apps/desktop/src/components/PluginMcpControls.test.ts b/apps/desktop/src/components/PluginMcpControls.test.ts index 1767ec4ca..9b8e47b13 100644 --- a/apps/desktop/src/components/PluginMcpControls.test.ts +++ b/apps/desktop/src/components/PluginMcpControls.test.ts @@ -4,25 +4,28 @@ import { describe, expect, it } from "vitest"; import { PluginMcpControls } from "./PluginMcpControls"; describe("plugin MCP controls", () => { - it("keeps all connection actions disabled until runtime enablement", () => { + it("offers explicit enablement while keeping diagnostics disabled", () => { const markup = renderToStaticMarkup( createElement(PluginMcpControls, { - targetId: "workspace", + spaceId: "workspace", server: "example/docs", enabled: false, + pluginActive: true, http: true, }), ); - expect(markup).toContain("Enable and apply this server explicitly"); + expect(markup).toContain("Enable all plugin tools"); + expect(markup).toContain("including tools"); expect(markup.match(/disabled=""/g)).toHaveLength(2); expect(markup).not.toContain("Sign in"); }); it("does not offer HTTP OAuth controls for a stdio server", () => { const markup = renderToStaticMarkup( createElement(PluginMcpControls, { - targetId: "workspace", + spaceId: "workspace", server: "example/local", enabled: true, + pluginActive: true, http: false, }), ); diff --git a/apps/desktop/src/components/PluginMcpControls.tsx b/apps/desktop/src/components/PluginMcpControls.tsx index 1a3b24bc9..135af6b34 100644 --- a/apps/desktop/src/components/PluginMcpControls.tsx +++ b/apps/desktop/src/components/PluginMcpControls.tsx @@ -5,22 +5,30 @@ import { beginManagedMcpOAuth, completeManagedMcpOAuth, diagnoseManagedMcpServer, + getManagedConfiguration, logoutManagedMcpOAuth, managedMcpOAuthStatus, + saveSpaceConfiguration, } from "../api"; import type { ManagedMcpOAuthLogin, ManagedMcpOAuthStatus } from "../types"; +import { pluginConnectionRequest } from "../pluginConnection"; -/** Explicit diagnostics and OAuth only; this component cannot enable a server. */ export function PluginMcpControls({ - targetId, + spaceId, server, enabled, + pluginActive, http, + sessionRequired = false, + onChanged, }: { - targetId: string; + spaceId: string; server: string; enabled: boolean; + pluginActive: boolean; http: boolean; + sessionRequired?: boolean; + onChanged?: (() => void) | undefined; }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -32,7 +40,7 @@ export function PluginMcpControls({ const [login, setLogin] = useState(null); const [callback, setCallback] = useState(""); async function run(operation: () => Promise) { - if (busy || !enabled) return; + if (busy || !enabled || !pluginActive) return; setBusy(true); setError(""); try { @@ -47,17 +55,51 @@ export function PluginMcpControls({ setBusy(false); } } + async function setEnabled(next: boolean) { + if (busy) return; + setBusy(true); + setError(""); + setMessage(""); + try { + const snapshot = await getManagedConfiguration(); + const request = pluginConnectionRequest(snapshot, spaceId, server, next); + await saveSpaceConfiguration(request); + setDiagnostic(null); + setStatus(null); + setMessage( + next + ? "Connection enabled. Test it after the runtime restarts." + : "Connection disabled for this workspace.", + ); + onChanged?.(); + } catch (error) { + setError( + error instanceof Error + ? error.message + : "Could not update this plugin connection.", + ); + } finally { + setBusy(false); + } + } return (

+ )}
- {!enabled && ( + {!pluginActive ? ( - Enable and apply this server explicitly in plugin settings first. + Activate this plugin digest before configuring its connection. - )} - {busy &&

Checking MCP connection…

} + ) : sessionRequired ? ( + + Classic Outlook needs the Windows session connection below. Its + sandboxed stdio connection cannot attach to the running Outlook + process. + + ) : !enabled ? ( + + A new connection permits every tool from this plugin, including tools + added by a later update. Configure an exact tool list in plugin + settings for narrower access. + + ) : null} + {busy &&

Updating or checking MCP connection…

} {diagnostic ? ( ) : ( @@ -107,14 +161,14 @@ export function PluginMcpControls({ {status?.configured && (