From 0641371bf23b258a2c89ada651c5f513a2649d10 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:15:44 -0700 Subject: [PATCH 01/54] Add extension manifest discovery and trust model --- Cargo.lock | 986 +++++++++++++++++++- crates/flowstate-extension/Cargo.toml | 23 + crates/flowstate-extension/src/discovery.rs | 56 ++ crates/flowstate-extension/src/lib.rs | 8 + crates/flowstate-extension/src/manifest.rs | 86 ++ crates/flowstate-extension/src/trust.rs | 37 + 6 files changed, 1184 insertions(+), 12 deletions(-) create mode 100644 crates/flowstate-extension/Cargo.toml create mode 100644 crates/flowstate-extension/src/discovery.rs create mode 100644 crates/flowstate-extension/src/lib.rs create mode 100644 crates/flowstate-extension/src/manifest.rs create mode 100644 crates/flowstate-extension/src/trust.rs diff --git a/Cargo.lock b/Cargo.lock index 311e03d5..75a5d91c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -89,6 +98,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -898,6 +913,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "by_address" @@ -967,9 +985,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bzip2" @@ -1006,6 +1024,84 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix 1.1.4", + "smallvec", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" +dependencies = [ + "ambient-authority", + "rand 0.8.6", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.4", + "winx", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1605,6 +1701,15 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpubits" version = "0.1.1" @@ -1629,6 +1734,144 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c088d3406f0c0252efa7445adfd2d05736bfb5218838f64eaf79d567077aed14" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c03f887a763abb9c1dc08f722aa82b69067fda623b6f0273050f45f8b1a6776" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206887a11a43f507fee320a218dc365980bfc42ec2696792079a9f8c9369e90" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac0790c83cfdab95709c5d0105fd888221e3af9049a7d7ec376ec901ab4e4dba" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a98aed2d262eda69310e84bae8e053ee4f17dbdd3347b8d9156aa618ba2de0a" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.15.5", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash 2.1.2", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-math", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6906852826988563e9b0a9232ad951f53a47aa41ffd02f8ac852d3f41aae836a" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck 0.5.0", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a50105aab667b5cc845f2be37c78475d7cc127cd8ec0a31f7b2b71d526099a7" + +[[package]] +name = "cranelift-control" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6adcc7aa7c0bc1727176a6f2d99c28a9e79a541ccd5ca911a0cb352da8befa36" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "981b56af777f9a34ea6dcce93255125776d391410c2a68b75bed5941b714fa15" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea982589684dfb71afecb9fc09555c3a266300a1162a60d7fa39d41a5705b1c" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0422686b22ed6a1f33cc40e3c43eb84b67155788568d1a5cac8439d3dca1783" + +[[package]] +name = "cranelift-native" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f697bbbe135c655ea1deb7af0bae4a5c4fae2c88fdfc0fa57b34ae58c91040" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "718efe674f3df645462677e22a3128e890d88ba55821bb091083d257707be76c" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1814,6 +2057,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -1867,6 +2119,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs" version = "4.0.0" @@ -2384,6 +2646,17 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -2581,6 +2854,23 @@ dependencies = [ "zstd", ] +[[package]] +name = "flowstate-extension" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "hex", + "parking_lot", + "serde", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "wasmtime", + "wasmtime-wasi", +] + [[package]] name = "flowstate-flow" version = "0.1.0" @@ -2747,6 +3037,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fs4" version = "0.13.1" @@ -2907,6 +3208,20 @@ dependencies = [ "byteorder", ] +[[package]] +name = "fxprof-processed-profile" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" +dependencies = [ + "bitflags 2.13.0", + "debugid", + "rustc-hash 2.1.2", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2990,6 +3305,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "git2" version = "0.20.4" @@ -3518,6 +3844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", + "serde", ] [[package]] @@ -4409,6 +4736,22 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + [[package]] name = "io-surface" version = "0.16.1" @@ -4496,6 +4839,26 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + [[package]] name = "jiff" version = "0.2.28" @@ -4665,6 +5028,12 @@ dependencies = [ "leak", ] +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -5021,6 +5390,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -5074,6 +5452,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -5109,6 +5493,15 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "memmap2" version = "0.9.10" @@ -5793,6 +6186,9 @@ version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap", "memchr", ] @@ -6456,16 +6852,39 @@ dependencies = [ ] [[package]] -name = "pxfm" -version = "0.1.29" +name = "pulley-interpreter" +version = "38.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "beafc309a2d35e16cc390644d88d14dfa45e45e15075ec6a9e37f6dfb43e926f" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-math", +] [[package]] -name = "qcms" -version = "0.3.0" +name = "pulley-macros" +version = "38.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edecfcd5d755a5e5d98e24cf43113e7cdaec5a070edd0f6b250c03a573da30fa" +checksum = "1885fbb6c07454cfc8725a18a1da3cfc328ee8c53fb8d0671ea313edc8567947" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qcms" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edecfcd5d755a5e5d98e24cf43113e7cdaec5a070edd0f6b250c03a573da30fa" [[package]] name = "qoi" @@ -6970,6 +7389,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "regalloc2" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08effbc1fa53aaebff69521a5c05640523fab037b34a4a2c109506bc938246fa" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash 2.1.2", + "smallvec", +] + [[package]] name = "regex" version = "1.12.3" @@ -7314,6 +7747,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.40" @@ -7536,6 +7979,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -7848,6 +8295,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smol" @@ -8309,6 +8759,22 @@ dependencies = [ "libc", ] +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags 2.13.0", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes", + "rustix 0.38.44", + "windows-sys 0.59.0", + "winx", +] + [[package]] name = "taffy" version = "0.9.0" @@ -8493,6 +8959,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -9815,6 +10287,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" +dependencies = [ + "leb128fmt", + "wasmparser 0.239.0", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -9825,6 +10307,16 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasm-encoder" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59972d6cd272259de647b7c1f1912e45e289c75ffd4be04e10695507cd7e1b59" +dependencies = [ + "leb128fmt", + "wasmparser 0.253.0", +] + [[package]] name = "wasm-metadata" version = "0.244.0" @@ -9833,7 +10325,7 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasmparser 0.244.0", ] @@ -9896,6 +10388,19 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", + "serde", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -9908,6 +10413,362 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19db11f87d2486580e1e8b6f494c54df7e0566b87d0b599db843c24019667339" +dependencies = [ + "bitflags 2.13.0", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3981f3d51f39f24f5fc90f93049a90f08dbbca8deba602cd46bb8ca67a94718" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.239.0", +] + +[[package]] +name = "wasmtime" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81eafc07c867be94c47e0dc66355d9785e09107a18901f76a20701ba0663ad7" +dependencies = [ + "addr2line", + "anyhow", + "async-trait", + "bitflags 2.13.0", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "fxprof-processed-profile", + "gimli", + "hashbrown 0.15.5", + "indexmap", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object 0.37.3", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "wasm-encoder 0.239.0", + "wasmparser 0.239.0", + "wasmtime-environ", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-math", + "wasmtime-internal-slab", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "wat", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-environ" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78587abe085a44a13c90fa16fea6db014e9883e627a7044d7f0cb397ad08d1da" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap", + "log", + "object 0.37.3", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder 0.239.0", + "wasmparser 0.239.0", + "wasmprinter", + "wasmtime-internal-component-util", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78fb9299e318b0af3efb75d88321515a20a5ccb040bcde1f0f7d46d656fa8fef" +dependencies = [ + "anyhow", + "base64", + "directories-next", + "log", + "postcard", + "rustix 1.1.4", + "serde", + "serde_derive", + "sha2 0.10.9", + "toml 0.8.23", + "windows-sys 0.60.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d843bb444f2d1509ea9304ad749242d1fa5de95cde67665bfcdcafa0f360925c" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.239.0", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801ee1a80ab66f065a88c6a62f2d495d5540d027b366757c6a53e9c42f153aef" + +[[package]] +name = "wasmtime-internal-cranelift" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb50f1c50365c32e557266ca85acdf77696c44a3f98797ba6af58cebc6d6d1e" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.14.0", + "log", + "object 0.37.3", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.239.0", + "wasmtime-environ", + "wasmtime-internal-math", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9308cdb17f8d51e3164185616d809e28c29a6515c03b9dd95c89436b71f6d154" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9b63a22bf2a8b6a149a41c6768bc17a8b2e3288a249cb8216987fbd7128e81" +dependencies = [ + "cc", + "object 0.37.3", + "rustix 1.1.4", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8e042b6e3de2f3d708279f89f50b4b9aa1b9bab177300cdffb0ffcd2816df5" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-math" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1f0674f38cd7d014eb1a49ea1d1766cca1a64459e8856ee118a10005302e16" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-internal-slab" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb24b7535306713e7a250f8b71e35f05b6a5031bf9c3ed7330c308e899cbe7d3" + +[[package]] +name = "wasmtime-internal-unwinder" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d5a80e2623a49cb8e8c419542337b8fe0260b162c40dcc201080a84cbe9b7c" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "log", + "object 0.37.3", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23e277f734b9256359b21517c3b0c26a2a9de6c53a51b670ae55cdcde548bf4e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4dc9333737142f6ece4369c8bcdda03a11edbd43d8fbd3e15004c194b9b743" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "log", + "object 0.37.3", + "target-lexicon", + "wasmparser 0.239.0", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f758625553fe33fdce0713f63bb7784c4f5fecb7f7cd4813414519ec24b6a4c" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "heck 0.5.0", + "indexmap", + "wit-parser 0.239.0", +] + +[[package]] +name = "wasmtime-wasi" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55abdad51f519217927f45eaae73ca0cd46eb76688628a49784f41b5b19b8ed6" +dependencies = [ + "anyhow", + "async-trait", + "bitflags 2.13.0", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "futures", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", + "system-interface", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489d7f6e8ea0c4842e31b01721527a825f55ae73a2fa095d8b3f7ddbd75e3661" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "futures", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "253.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3264542f8965c5d84fb1085d924bfba9a6314bb228eff13a2de14d7627664d0" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width 0.2.2", + "wasm-encoder 0.253.0", +] + +[[package]] +name = "wat" +version = "1.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bfc5ce906144200c972ec617470aa35bd847472e170b26dde3e80541c674055" +dependencies = [ + "wast 253.0.0", +] + [[package]] name = "wayland-backend" version = "0.3.15" @@ -10056,6 +10917,47 @@ dependencies = [ "winsafe", ] +[[package]] +name = "wiggle" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9ee0c6dd73bdf0aff4404059bdc24ca61ad92056d20f4e59b8b0780789cafb4" +dependencies = [ + "anyhow", + "async-trait", + "bitflags 2.13.0", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e415549583fd492ccab881076fa5c41590362d3b5e99df793f619d67333c97b" +dependencies = [ + "anyhow", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1a533b4fdc593bf9c4bf52ae0b3a126f15babfb25fce03bfe0bcc84e1172222" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "wiggle-generate", +] + [[package]] name = "winapi" version = "0.3.9" @@ -10087,6 +10989,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c0bb17ae9bf89ebc74512150e6ee0a27b1eac5ff3b54d8cec264f4b4255022d" +dependencies = [ + "anyhow", + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.239.0", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "wasmtime-internal-math", +] + [[package]] name = "windows" version = "0.57.0" @@ -10597,6 +11519,16 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.0", + "windows-sys 0.59.0", +] + [[package]] name = "wio" version = "0.2.2" @@ -10629,7 +11561,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck 0.5.0", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -10676,10 +11608,28 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", "wasmparser 0.244.0", - "wit-parser", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.239.0", ] [[package]] @@ -10700,6 +11650,18 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + [[package]] name = "workspace-hack" version = "0.1.0" diff --git a/crates/flowstate-extension/Cargo.toml b/crates/flowstate-extension/Cargo.toml new file mode 100644 index 00000000..308023a5 --- /dev/null +++ b/crates/flowstate-extension/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "flowstate-extension" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +bytes = "1.12.1" +hex = "0.4.3" +parking_lot = "0.12.5" +serde = { workspace = true, features = ["derive"] } +sha2 = "0.11.0" +thiserror = "2.0.18" +toml.workspace = true +wasmtime = { version = "38", features = ["component-model", "cranelift", "runtime"] } +wasmtime-wasi = "38" + +[lints] +workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/flowstate-extension/src/discovery.rs b/crates/flowstate-extension/src/discovery.rs new file mode 100644 index 00000000..db3d6037 --- /dev/null +++ b/crates/flowstate-extension/src/discovery.rs @@ -0,0 +1,56 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::ExtensionManifest; + +#[derive(Clone, Debug)] +pub struct InstalledExtension { + pub manifest: ExtensionManifest, + pub root: PathBuf, + pub component_path: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DiscoveryIssue { + pub path: PathBuf, + pub message: String, +} + +#[derive(Clone, Debug, Default)] +pub struct DiscoveryResult { + pub extensions: Vec, + pub issues: Vec, +} + +pub fn discover_extensions(root: &Path) -> DiscoveryResult { + let mut result = DiscoveryResult::default(); + let Ok(entries) = fs::read_dir(root) else { return result }; + let mut manifests: Vec<_> = entries.flatten().map(|entry| entry.path().join("extension.toml")).filter(|path| path.is_file()).collect(); + manifests.sort(); + let mut ids = HashSet::new(); + for path in manifests { + match load_extension(&path) { + Ok(extension) if ids.insert(extension.manifest.id.clone()) => result.extensions.push(extension), + Ok(extension) => result.issues.push(DiscoveryIssue { path, message: format!("duplicate extension id: {}", extension.manifest.id) }), + Err(message) => result.issues.push(DiscoveryIssue { path, message }), + } + } + result +} + +fn load_extension(path: &Path) -> Result { + let source = fs::read_to_string(path).map_err(|error| error.to_string())?; + let manifest = ExtensionManifest::parse(&source).map_err(|error| error.to_string())?; + let root = path.parent().ok_or("manifest has no parent directory")?.to_path_buf(); + let component_path = root.join(&manifest.component); + if !component_path.is_file() { + return Err(format!("component does not exist: {}", component_path.display())); + } + let canonical_root = root.canonicalize().map_err(|error| error.to_string())?; + let canonical_component = component_path.canonicalize().map_err(|error| error.to_string())?; + if !canonical_component.starts_with(&canonical_root) { + return Err("component resolves outside the extension directory".to_owned()); + } + Ok(InstalledExtension { manifest, root, component_path: canonical_component }) +} diff --git a/crates/flowstate-extension/src/lib.rs b/crates/flowstate-extension/src/lib.rs new file mode 100644 index 00000000..7b1ab7c2 --- /dev/null +++ b/crates/flowstate-extension/src/lib.rs @@ -0,0 +1,8 @@ +mod discovery; +mod manifest; +mod trust; + +pub use discovery::{DiscoveryIssue, DiscoveryResult, InstalledExtension, discover_extensions}; +pub use manifest::{ActionManifest, ExtensionManifest, ManifestError}; +pub use trust::{ComponentDigest, TrustDecision, TrustStore}; + diff --git a/crates/flowstate-extension/src/manifest.rs b/crates/flowstate-extension/src/manifest.rs new file mode 100644 index 00000000..8989ab8c --- /dev/null +++ b/crates/flowstate-extension/src/manifest.rs @@ -0,0 +1,86 @@ +use std::collections::HashSet; +use std::path::{Component, Path}; + +use serde::Deserialize; +use thiserror::Error; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ExtensionManifest { + pub manifest_version: u32, + pub id: String, + pub name: String, + pub version: String, + pub component: String, + #[serde(default)] + pub actions: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ActionManifest { + pub id: String, + pub label: String, + #[serde(default)] + pub requires_document: bool, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ManifestError { + #[error("manifest TOML is invalid: {0}")] + Toml(String), + #[error("unsupported manifest version {0}")] + UnsupportedVersion(u32), + #[error("{field} must be a dot-separated identifier")] + InvalidId { field: &'static str }, + #[error("{field} must not be empty")] + Empty { field: &'static str }, + #[error("component must be a relative path without parent traversal")] + InvalidComponentPath, + #[error("duplicate action id: {0}")] + DuplicateAction(String), + #[error("at least one action is required")] + NoActions, +} + +impl ExtensionManifest { + pub fn parse(source: &str) -> Result { + let manifest: Self = toml::from_str(source).map_err(|error| ManifestError::Toml(error.to_string()))?; + manifest.validate()?; + Ok(manifest) + } + + pub fn validate(&self) -> Result<(), ManifestError> { + if self.manifest_version != 1 { + return Err(ManifestError::UnsupportedVersion(self.manifest_version)); + } + validate_id(&self.id, "id")?; + require_text(&self.name, "name")?; + require_text(&self.version, "version")?; + let path = Path::new(&self.component); + if path.is_absolute() || path.as_os_str().is_empty() || path.components().any(|part| matches!(part, Component::ParentDir | Component::RootDir | Component::Prefix(_))) { + return Err(ManifestError::InvalidComponentPath); + } + if self.actions.is_empty() { + return Err(ManifestError::NoActions); + } + let mut ids = HashSet::new(); + for action in &self.actions { + validate_id(&action.id, "action id")?; + require_text(&action.label, "action label")?; + if !ids.insert(&action.id) { + return Err(ManifestError::DuplicateAction(action.id.clone())); + } + } + Ok(()) + } +} + +fn require_text(value: &str, field: &'static str) -> Result<(), ManifestError> { + if value.trim().is_empty() { Err(ManifestError::Empty { field }) } else { Ok(()) } +} + +fn validate_id(value: &str, field: &'static str) -> Result<(), ManifestError> { + let valid = !value.is_empty() && value.split('.').all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))); + if valid { Ok(()) } else { Err(ManifestError::InvalidId { field }) } +} diff --git a/crates/flowstate-extension/src/trust.rs b/crates/flowstate-extension/src/trust.rs new file mode 100644 index 00000000..1c3980d3 --- /dev/null +++ b/crates/flowstate-extension/src/trust.rs @@ -0,0 +1,37 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ComponentDigest(String); + +impl ComponentDigest { + pub fn from_file(path: &Path) -> std::io::Result { + Ok(Self(hex::encode(Sha256::digest(fs::read(path)?)))) + } + + pub fn as_str(&self) -> &str { &self.0 } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TrustDecision { Trusted, ApprovalRequired } + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TrustStore(HashMap); + +impl TrustStore { + pub fn decision(&self, extension_id: &str, digest: &ComponentDigest) -> TrustDecision { + if self.0.get(extension_id) == Some(digest) { TrustDecision::Trusted } else { TrustDecision::ApprovalRequired } + } + + pub fn approve(&mut self, extension_id: impl Into, digest: ComponentDigest) { + self.0.insert(extension_id.into(), digest); + } + + pub fn revoke(&mut self, extension_id: &str) { self.0.remove(extension_id); } +} From 85b5a48143442d68aac6b51c01b29e94c6a2c731 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:15:46 -0700 Subject: [PATCH 02/54] Add extension panel view model seam --- .../src/workspace/extensions_panel/mod.rs | 5 + .../src/workspace/extensions_panel/model.rs | 99 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 crates/flowstate/src/workspace/extensions_panel/mod.rs create mode 100644 crates/flowstate/src/workspace/extensions_panel/model.rs diff --git a/crates/flowstate/src/workspace/extensions_panel/mod.rs b/crates/flowstate/src/workspace/extensions_panel/mod.rs new file mode 100644 index 00000000..c4bbff8d --- /dev/null +++ b/crates/flowstate/src/workspace/extensions_panel/mod.rs @@ -0,0 +1,5 @@ +mod model; + +pub use model::{ + ExtensionActionView, ExtensionPanelAdapter, ExtensionPanelController, ExtensionPanelEvent, ExtensionRunState, ExtensionView, +}; diff --git a/crates/flowstate/src/workspace/extensions_panel/model.rs b/crates/flowstate/src/workspace/extensions_panel/model.rs new file mode 100644 index 00000000..4b6507ad --- /dev/null +++ b/crates/flowstate/src/workspace/extensions_panel/model.rs @@ -0,0 +1,99 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use gpui::SharedString; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExtensionActionView { + pub id: SharedString, + pub label: SharedString, + pub requires_document: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExtensionView { + pub id: SharedString, + pub name: SharedString, + pub version: SharedString, + pub component_hash: SharedString, + pub actions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExtensionRunState { + Idle, + Running { action_id: SharedString }, + Failed(SharedString), + Cancelled, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ExtensionPanelEvent { + Started { extension_id: SharedString, action_id: SharedString }, + ActionLabel { extension_id: SharedString, action_id: SharedString, label: SharedString }, + Output { extension_id: SharedString, output: SharedString }, + Failed { extension_id: SharedString, message: SharedString }, + Finished { extension_id: SharedString }, + Cancelled { extension_id: SharedString }, +} + +pub trait ExtensionPanelAdapter: Send + Sync { + fn installed(&self) -> Result, SharedString>; + fn invoke(&self, extension_id: &str, action_id: &str) -> Result<(), SharedString>; + fn cancel(&self, extension_id: &str) -> Result<(), SharedString>; +} + +pub struct ExtensionPanelController { + adapter: Arc, + extensions: Vec, + states: BTreeMap, + labels: BTreeMap<(SharedString, SharedString), SharedString>, + outputs: BTreeMap, + error: Option, +} + +impl ExtensionPanelController { + pub fn new(adapter: Arc) -> Self { + Self { + adapter, + extensions: Vec::new(), + states: BTreeMap::new(), + labels: BTreeMap::new(), + outputs: BTreeMap::new(), + error: None, + } + } + + pub fn reload(&mut self) { + match self.adapter.installed() { + Ok(extensions) => { + self.extensions = extensions; + self.error = None; + }, + Err(error) => self.error = Some(error), + } + } + + pub fn extensions(&self) -> &[ExtensionView] { + &self.extensions + } + + pub fn state(&self, extension_id: &str) -> ExtensionRunState { + self.states.get(extension_id).cloned().unwrap_or(ExtensionRunState::Idle) + } + + pub fn label(&self, extension_id: &str, action: &ExtensionActionView) -> SharedString { + self + .labels + .get(&(extension_id.into(), action.id.clone())) + .cloned() + .unwrap_or_else(|| action.label.clone()) + } + + pub fn output(&self, extension_id: &str) -> Option<&SharedString> { + self.outputs.get(extension_id) + } + + pub fn error(&self) -> Option<&SharedString> { + self.error.as_ref() + } +} From 284f72f886b4266386d3940e9e186d14e24fed96 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:16:10 -0700 Subject: [PATCH 03/54] Add editor extension API types --- .../src/rich_text/editor/extension_api.rs | 73 +++++++++++++++++++ .../gpui-flowtext/src/rich_text/editor/mod.rs | 1 + 2 files changed, 74 insertions(+) create mode 100644 crates/gpui-flowtext/src/rich_text/editor/extension_api.rs diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs new file mode 100644 index 00000000..8f6b6823 --- /dev/null +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs @@ -0,0 +1,73 @@ +use std::{fmt, ops::Range}; + +use super::*; + +#[derive(Clone, Debug)] +pub struct ExtensionDocumentSnapshot { + pub generation: u64, + pub document: Document, + pub selection: ExtensionSelection, + pub selected_text: String, + pub selected_fragment: RichClipboardFragment, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ExtensionSelection { + Text(EditorSelection), + Object { block_ix: usize }, + TableCell { + block_ix: usize, + row_ix: usize, + cell_ix: usize, + anchor: usize, + head: usize, + }, +} + +#[derive(Clone, Debug)] +pub enum ExtensionDocumentEdit { + ReplaceText { + range: Range, + fragment: RichClipboardFragment, + }, + ReplaceBlock { + block_ix: usize, + block: Block, + }, + ReplaceTableCell { + block_ix: usize, + row_ix: usize, + cell_ix: usize, + blocks: Vec, + }, + ReplaceDocument(Document), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ExtensionEditError { + StaleGeneration { expected: u64, actual: u64 }, + ReadOnly, + InvalidRange, + InvalidBlock(usize), + NotATable(usize), + InvalidTableCell { block_ix: usize, row_ix: usize, cell_ix: usize }, + InvalidDocument, +} + +impl fmt::Display for ExtensionEditError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::StaleGeneration { expected, actual } => write!(formatter, "document generation changed (expected {expected}, found {actual})"), + Self::ReadOnly => formatter.write_str("document is read-only"), + Self::InvalidRange => formatter.write_str("text range is outside the document or not on UTF-8 boundaries"), + Self::InvalidBlock(block_ix) => write!(formatter, "block index {block_ix} is outside the document"), + Self::NotATable(block_ix) => write!(formatter, "block {block_ix} is not a table"), + Self::InvalidTableCell { block_ix, row_ix, cell_ix } => { + write!(formatter, "table cell {block_ix}:{row_ix}:{cell_ix} does not exist") + }, + Self::InvalidDocument => formatter.write_str("replacement would leave an invalid document"), + } + } +} + +impl std::error::Error for ExtensionEditError {} diff --git a/crates/gpui-flowtext/src/rich_text/editor/mod.rs b/crates/gpui-flowtext/src/rich_text/editor/mod.rs index 646f429d..83b57594 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mod.rs @@ -945,6 +945,7 @@ pub struct RichTextEditor { } include!("lifecycle.rs"); +include!("extension_api.rs"); include!("object_selection.rs"); include!("style_state.rs"); include!("search_highlights.rs"); From 087fd34a7dac9d8fc40f2ccda66956daabda12f4 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:16:18 -0700 Subject: [PATCH 04/54] Wire extension panel controller into workspace --- .../src/workspace/extensions_panel/mod.rs | 32 ++++++++++ .../src/workspace/extensions_panel/model.rs | 58 +++++++++++++++++++ crates/flowstate/src/workspace/mod.rs | 1 + .../src/workspace/workspace/documents.rs | 1 + .../flowstate/src/workspace/workspace/mod.rs | 3 + 5 files changed, 95 insertions(+) diff --git a/crates/flowstate/src/workspace/extensions_panel/mod.rs b/crates/flowstate/src/workspace/extensions_panel/mod.rs index c4bbff8d..0a53f864 100644 --- a/crates/flowstate/src/workspace/extensions_panel/mod.rs +++ b/crates/flowstate/src/workspace/extensions_panel/mod.rs @@ -1,5 +1,37 @@ mod model; +use std::sync::Arc; + +use gpui::SharedString; + pub use model::{ ExtensionActionView, ExtensionPanelAdapter, ExtensionPanelController, ExtensionPanelEvent, ExtensionRunState, ExtensionView, }; + +struct DisconnectedExtensionAdapter; + +impl ExtensionPanelAdapter for DisconnectedExtensionAdapter { + fn installed(&self) -> Result, SharedString> { + Ok(Vec::new()) + } + + fn is_trusted(&self, _: &str, _: &str) -> bool { + false + } + + fn trust(&self, _: &str, _: &str) -> Result<(), SharedString> { + Err("Extension runtime is not connected".into()) + } + + fn invoke(&self, _: &str, _: &str) -> Result<(), SharedString> { + Err("Extension runtime is not connected".into()) + } + + fn cancel(&self, _: &str) -> Result<(), SharedString> { + Err("Extension runtime is not connected".into()) + } +} + +pub fn disconnected_controller() -> ExtensionPanelController { + ExtensionPanelController::new(Arc::new(DisconnectedExtensionAdapter)) +} diff --git a/crates/flowstate/src/workspace/extensions_panel/model.rs b/crates/flowstate/src/workspace/extensions_panel/model.rs index 4b6507ad..a8b08c6a 100644 --- a/crates/flowstate/src/workspace/extensions_panel/model.rs +++ b/crates/flowstate/src/workspace/extensions_panel/model.rs @@ -38,6 +38,8 @@ pub enum ExtensionPanelEvent { pub trait ExtensionPanelAdapter: Send + Sync { fn installed(&self) -> Result, SharedString>; + fn is_trusted(&self, extension_id: &str, component_hash: &str) -> bool; + fn trust(&self, extension_id: &str, component_hash: &str) -> Result<(), SharedString>; fn invoke(&self, extension_id: &str, action_id: &str) -> Result<(), SharedString>; fn cancel(&self, extension_id: &str) -> Result<(), SharedString>; } @@ -96,4 +98,60 @@ impl ExtensionPanelController { pub fn error(&self) -> Option<&SharedString> { self.error.as_ref() } + + pub fn requires_trust(&self, extension_id: &str) -> bool { + self + .extensions + .iter() + .find(|extension| extension.id.as_ref() == extension_id) + .is_some_and(|extension| !self.adapter.is_trusted(extension_id, extension.component_hash.as_ref())) + } + + pub fn trust(&mut self, extension_id: &str) -> Result<(), SharedString> { + let extension = self + .extensions + .iter() + .find(|extension| extension.id.as_ref() == extension_id) + .ok_or_else(|| SharedString::from("Extension is no longer installed"))?; + self.adapter.trust(extension_id, extension.component_hash.as_ref()) + } + + pub fn invoke(&mut self, extension_id: &str, action_id: &str) { + self + .states + .insert(extension_id.into(), ExtensionRunState::Running { action_id: action_id.into() }); + self.outputs.remove(extension_id); + if let Err(error) = self.adapter.invoke(extension_id, action_id) { + self.states.insert(extension_id.into(), ExtensionRunState::Failed(error)); + } + } + + pub fn cancel(&mut self, extension_id: &str) { + if let Err(error) = self.adapter.cancel(extension_id) { + self.states.insert(extension_id.into(), ExtensionRunState::Failed(error)); + } + } + + pub fn apply(&mut self, event: ExtensionPanelEvent) { + match event { + ExtensionPanelEvent::Started { extension_id, action_id } => { + self.states.insert(extension_id, ExtensionRunState::Running { action_id }); + }, + ExtensionPanelEvent::ActionLabel { extension_id, action_id, label } => { + self.labels.insert((extension_id, action_id), label); + }, + ExtensionPanelEvent::Output { extension_id, output } => { + self.outputs.insert(extension_id, output); + }, + ExtensionPanelEvent::Failed { extension_id, message } => { + self.states.insert(extension_id, ExtensionRunState::Failed(message)); + }, + ExtensionPanelEvent::Finished { extension_id } => { + self.states.insert(extension_id, ExtensionRunState::Idle); + }, + ExtensionPanelEvent::Cancelled { extension_id } => { + self.states.insert(extension_id, ExtensionRunState::Cancelled); + }, + } + } } diff --git a/crates/flowstate/src/workspace/mod.rs b/crates/flowstate/src/workspace/mod.rs index e4a2001f..8bdf6d37 100644 --- a/crates/flowstate/src/workspace/mod.rs +++ b/crates/flowstate/src/workspace/mod.rs @@ -1,6 +1,7 @@ mod document_panel; pub mod document_search; pub mod document_search_overlay; +mod extensions_panel; mod file_management; mod file_search_overlay; mod icons; diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index 7255ce76..6c6dd082 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -57,6 +57,7 @@ impl Workspace { outline_collapsed: false, toolkit_collapsed: false, active_toolkit_tool: None, + extensions: super::super::extensions_panel::disconnected_controller(), recent_documents: load_recent_documents(), recent_document_previews: HashMap::new(), recent_document_preview_generation: 0, diff --git a/crates/flowstate/src/workspace/workspace/mod.rs b/crates/flowstate/src/workspace/workspace/mod.rs index 230b9d30..bd161cfd 100644 --- a/crates/flowstate/src/workspace/workspace/mod.rs +++ b/crates/flowstate/src/workspace/workspace/mod.rs @@ -53,6 +53,7 @@ use crate::workspace::file_management::{ UNTITLED_DOCUMENT_NAME, UNTITLED_FLOW_NAME, default_save_directory, new_blank_document, normalize_db8_path, normalize_fl0_path, }; use crate::workspace::file_search_overlay::FileSearchOverlay; +use crate::workspace::extensions_panel::ExtensionPanelController; use crate::workspace::icons::{AppIcon, icon_button}; use flowstate_tub::{SearchHit, SearchUnitKind, TubFile, TubIndex, TubTreeNode}; @@ -72,6 +73,7 @@ pub struct Workspace { outline_collapsed: bool, toolkit_collapsed: bool, active_toolkit_tool: Option, + extensions: ExtensionPanelController, recent_documents: Vec, recent_document_previews: HashMap, recent_document_preview_generation: u64, @@ -183,6 +185,7 @@ enum LeftNavMode { #[derive(Clone, Copy, PartialEq, Eq)] enum ToolkitTool { Tub, + Extensions, } #[derive(Clone, Copy, PartialEq, Eq)] From e4d2ce17101c2f36cb2039e515e1d16ff5018a96 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:17:03 -0700 Subject: [PATCH 05/54] Add Extensions tool to workspace rail --- .../flowstate/src/workspace/toolkit_panel.rs | 22 +++++++++++++++++-- .../src/workspace/workspace/documents.rs | 1 + .../flowstate/src/workspace/workspace/mod.rs | 3 +++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/flowstate/src/workspace/toolkit_panel.rs b/crates/flowstate/src/workspace/toolkit_panel.rs index 0a235b57..cc929e53 100644 --- a/crates/flowstate/src/workspace/toolkit_panel.rs +++ b/crates/flowstate/src/workspace/toolkit_panel.rs @@ -93,8 +93,10 @@ impl Workspace { } fn render_toolkit_rail_area(&self, cx: &mut Context) -> impl IntoElement { - if self.active_toolkit_tool == Some(ToolkitTool::Tub) { - return self.render_toolkit_expanded(cx).into_any_element(); + match self.active_toolkit_tool { + Some(ToolkitTool::Tub) => return self.render_toolkit_expanded(cx).into_any_element(), + Some(ToolkitTool::Extensions) => return self.render_extensions_panel(cx).into_any_element(), + None => {}, } self.render_toolkit_icon_bar(cx).into_any_element() @@ -102,6 +104,7 @@ impl Workspace { fn render_toolkit_icon_bar(&self, cx: &mut Context) -> impl IntoElement { let tub_selected = self.active_toolkit_tool == Some(ToolkitTool::Tub); + let extensions_selected = self.active_toolkit_tool == Some(ToolkitTool::Extensions); v_flex() .h_full() @@ -142,6 +145,21 @@ impl Workspace { workspace.toggle_toolkit_tool(ToolkitTool::Tub, cx); })), ) + .child( + Button::new("toolkit-extensions-tool") + .icon(Icon::new(IconName::Bot).text_color(if extensions_selected { + cx.theme().link + } else { + cx.theme().muted_foreground + })) + .xsmall() + .ghost() + .selected(extensions_selected) + .tooltip("Extensions") + .on_click(cx.listener(|workspace, _, _, cx| { + workspace.toggle_toolkit_tool(ToolkitTool::Extensions, cx); + })), + ) .child(div().flex_1()) .child( Button::new("collapse-toolkit-rail") diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index 6c6dd082..0f3cd1e4 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -58,6 +58,7 @@ impl Workspace { toolkit_collapsed: false, active_toolkit_tool: None, extensions: super::super::extensions_panel::disconnected_controller(), + expanded_extensions: HashSet::new(), recent_documents: load_recent_documents(), recent_document_previews: HashMap::new(), recent_document_preview_generation: 0, diff --git a/crates/flowstate/src/workspace/workspace/mod.rs b/crates/flowstate/src/workspace/workspace/mod.rs index bd161cfd..5b345196 100644 --- a/crates/flowstate/src/workspace/workspace/mod.rs +++ b/crates/flowstate/src/workspace/workspace/mod.rs @@ -62,6 +62,8 @@ const SIDE_PANEL_COLLAPSED_WIDTH: Pixels = px(30.0); #[path = "../toolkit_panel.rs"] mod toolkit_panel; +#[path = "../extensions_tool.rs"] +mod extensions_tool; pub struct Workspace { document_panels: Vec>, @@ -74,6 +76,7 @@ pub struct Workspace { toolkit_collapsed: bool, active_toolkit_tool: Option, extensions: ExtensionPanelController, + expanded_extensions: HashSet, recent_documents: Vec, recent_document_previews: HashMap, recent_document_preview_generation: u64, From 83f00094b37c4fb0b0f6e51d9641d8c5fc7f7f8a Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:17:23 -0700 Subject: [PATCH 06/54] Add extension panel interactions and trust prompt --- .../src/workspace/extensions_tool.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 crates/flowstate/src/workspace/extensions_tool.rs diff --git a/crates/flowstate/src/workspace/extensions_tool.rs b/crates/flowstate/src/workspace/extensions_tool.rs new file mode 100644 index 00000000..b3870b55 --- /dev/null +++ b/crates/flowstate/src/workspace/extensions_tool.rs @@ -0,0 +1,64 @@ +use gpui::{Context, PromptButton, PromptLevel, SharedString, Window}; + +use super::Workspace; + +impl Workspace { + fn reload_extensions(&mut self, cx: &mut Context) { + self.extensions.reload(); + self + .expanded_extensions + .retain(|id| self.extensions.extensions().iter().any(|extension| &extension.id == id)); + cx.notify(); + } + + fn request_extension_action( + &mut self, + extension_id: SharedString, + action_id: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + if !self.extensions.requires_trust(extension_id.as_ref()) { + self.extensions.invoke(extension_id.as_ref(), action_id.as_ref()); + cx.notify(); + return; + } + + let answer = window.prompt( + PromptLevel::Warning, + "Run trusted extension?", + Some("Extensions can access your active document, its folder, the network, and their own data. Only continue if you trust this extension."), + &[PromptButton::ok("Trust and Run"), PromptButton::cancel("Cancel")], + cx, + ); + cx.spawn(async move |workspace, cx| { + if !matches!(answer.await, Ok(0)) { + return; + } + let _ = workspace.update(cx, |workspace, cx| { + if let Err(error) = workspace.extensions.trust(extension_id.as_ref()) { + workspace.extensions.apply(super::super::extensions_panel::ExtensionPanelEvent::Failed { + extension_id, + message: error, + }); + } else { + workspace.extensions.invoke(extension_id.as_ref(), action_id.as_ref()); + } + cx.notify(); + }); + }) + .detach(); + } + + fn cancel_extension(&mut self, extension_id: SharedString, cx: &mut Context) { + self.extensions.cancel(extension_id.as_ref()); + cx.notify(); + } + + fn toggle_extension_group(&mut self, extension_id: SharedString, cx: &mut Context) { + if !self.expanded_extensions.remove(&extension_id) { + self.expanded_extensions.insert(extension_id); + } + cx.notify(); + } +} From b0cbf98997e93d37802377e515bbfec41702192e Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:17:49 -0700 Subject: [PATCH 07/54] Expose extension document snapshots --- .../rich_text/editor/extension_snapshot.rs | 47 +++++++++++++++++++ .../gpui-flowtext/src/rich_text/editor/mod.rs | 1 + 2 files changed, 48 insertions(+) create mode 100644 crates/gpui-flowtext/src/rich_text/editor/extension_snapshot.rs diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_snapshot.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_snapshot.rs new file mode 100644 index 00000000..e142d287 --- /dev/null +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_snapshot.rs @@ -0,0 +1,47 @@ +impl RichTextEditor { + #[must_use] + pub fn extension_snapshot(&self) -> ExtensionDocumentSnapshot { + let range = self.selection.normalized(); + let text_fragment = selected_rich_fragment(&self.document, range.clone()); + let (selection, selected_text, selected_fragment) = match self.selected_block { + Some(BlockSelection::TableCell { + block_ix, + row_ix, + cell_ix, + }) => { + let fragment = self.selected_table_cell_fragment().unwrap_or(text_fragment); + ( + ExtensionSelection::TableCell { + block_ix, + row_ix, + cell_ix, + anchor: self.table_cell_anchor, + head: self.table_cell_caret, + }, + block_fragment_plain_text(&fragment), + fragment, + ) + }, + Some(BlockSelection::Image(block_ix) | BlockSelection::Equation(block_ix) | BlockSelection::Table(block_ix)) => { + let fragment = self.selected_block_fragment().unwrap_or(text_fragment); + ( + ExtensionSelection::Object { block_ix }, + block_fragment_plain_text(&fragment), + fragment, + ) + }, + None => ( + ExtensionSelection::Text(self.selection.clone()), + selected_plain_text(&self.document, range), + text_fragment, + ), + }; + ExtensionDocumentSnapshot { + generation: self.edit_generation, + document: self.document.clone(), + selection, + selected_text, + selected_fragment, + } + } +} diff --git a/crates/gpui-flowtext/src/rich_text/editor/mod.rs b/crates/gpui-flowtext/src/rich_text/editor/mod.rs index 83b57594..deee5a96 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mod.rs @@ -946,6 +946,7 @@ pub struct RichTextEditor { include!("lifecycle.rs"); include!("extension_api.rs"); +include!("extension_snapshot.rs"); include!("object_selection.rs"); include!("style_state.rs"); include!("search_highlights.rs"); From 711b0f7c5174b9bd35cd425b0ad3024f24a21c9a Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:18:59 -0700 Subject: [PATCH 08/54] Apply extension edits transactionally --- .../src/rich_text/editor/extension_api.rs | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs index 8f6b6823..e7582cc8 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs @@ -1,4 +1,4 @@ -use std::{fmt, ops::Range}; +use std::{fmt, ops::Range, sync::Arc}; use super::*; @@ -71,3 +71,113 @@ impl fmt::Display for ExtensionEditError { } impl std::error::Error for ExtensionEditError {} + +impl RichTextEditor { + pub fn apply_extension_edits( + &mut self, + expected_generation: u64, + edits: &[ExtensionDocumentEdit], + cx: &mut Context, + ) -> Result { + if expected_generation != self.edit_generation { + return Err(ExtensionEditError::StaleGeneration { + expected: expected_generation, + actual: self.edit_generation, + }); + } + if !self.can_write_collaboration() { + return Err(ExtensionEditError::ReadOnly); + } + if edits.is_empty() { + return Ok(self.edit_generation); + } + + let mut replacement = self.document.clone(); + for edit in edits { + apply_extension_edit(&mut replacement, edit)?; + } + rebuild_document_sections(&mut replacement); + reconcile_document_ids(&mut replacement); + if document_bytes(&replacement).is_err() { + return Err(ExtensionEditError::InvalidDocument); + } + + let before_selection = self.selection.clone(); + let before = std::mem::replace(&mut self.document, replacement); + self.selection.anchor = clamped_extension_offset(&self.document, self.selection.anchor); + self.selection.head = clamped_extension_offset(&self.document, self.selection.head); + self.selected_block = None; + self.push_replace_document_history(before, before_selection, cx); + Ok(self.edit_generation) + } +} + +fn apply_extension_edit(document: &mut Document, edit: &ExtensionDocumentEdit) -> Result<(), ExtensionEditError> { + match edit { + ExtensionDocumentEdit::ReplaceText { range, fragment } => { + if range.start > range.end + || !paragraph_offset_in_bounds(document, range.start) + || !paragraph_offset_in_bounds(document, range.end) + { + return Err(ExtensionEditError::InvalidRange); + } + let start_text = paragraph_text(document, range.start.paragraph); + let end_text = paragraph_text(document, range.end.paragraph); + if !start_text.is_char_boundary(range.start.byte) || !end_text.is_char_boundary(range.end.byte) { + return Err(ExtensionEditError::InvalidRange); + } + delete_cross_paragraph_range(document, range.clone()); + for asset in &fragment.assets { + document.assets.assets.insert( + asset.id, + AssetRecord { + id: asset.id, + mime_type: asset.mime_type.clone().into(), + original_name: asset.original_name.clone().map(Into::into), + content_hash: asset.content_hash, + bytes: Arc::new(asset.bytes.clone()), + }, + ); + } + insert_rich_fragment_at(document, range.start, fragment); + }, + ExtensionDocumentEdit::ReplaceBlock { block_ix, block } => { + let Some(slot) = Arc::make_mut(&mut document.blocks).get_mut(*block_ix) else { + return Err(ExtensionEditError::InvalidBlock(*block_ix)); + }; + *slot = block.clone(); + }, + ExtensionDocumentEdit::ReplaceTableCell { + block_ix, + row_ix, + cell_ix, + blocks, + } => { + let Some(block) = Arc::make_mut(&mut document.blocks).get_mut(*block_ix) else { + return Err(ExtensionEditError::InvalidBlock(*block_ix)); + }; + let Block::Table(table) = block else { + return Err(ExtensionEditError::NotATable(*block_ix)); + }; + let Some(cell) = table.rows.get_mut(*row_ix).and_then(|row| row.cells.get_mut(*cell_ix)) else { + return Err(ExtensionEditError::InvalidTableCell { + block_ix: *block_ix, + row_ix: *row_ix, + cell_ix: *cell_ix, + }); + }; + cell.blocks.clone_from(blocks); + table.version = table.version.wrapping_add(1); + }, + ExtensionDocumentEdit::ReplaceDocument(replacement) => *document = replacement.clone(), + } + Ok(()) +} + +fn clamped_extension_offset(document: &Document, offset: DocumentOffset) -> DocumentOffset { + let paragraph = offset.paragraph.min(document.paragraphs.len().saturating_sub(1)); + DocumentOffset { + paragraph, + byte: offset.byte.min(document.paragraphs.get(paragraph).map_or(0, paragraph_text_len)), + } +} From 988766d2e5df2099f5c52961b0caf63e616d33c3 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:20:06 -0700 Subject: [PATCH 09/54] Define extension component interface --- crates/flowstate-extension/src/lib.rs | 3 ++- crates/flowstate-extension/wit/extension.wit | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 crates/flowstate-extension/wit/extension.wit diff --git a/crates/flowstate-extension/src/lib.rs b/crates/flowstate-extension/src/lib.rs index 7b1ab7c2..c628780c 100644 --- a/crates/flowstate-extension/src/lib.rs +++ b/crates/flowstate-extension/src/lib.rs @@ -1,8 +1,9 @@ mod discovery; mod manifest; +mod runtime; mod trust; pub use discovery::{DiscoveryIssue, DiscoveryResult, InstalledExtension, discover_extensions}; pub use manifest::{ActionManifest, ExtensionManifest, ManifestError}; +pub use runtime::{CancellationHandle, ExtensionHost, HostError, Invocation, InvocationOutput, Runtime, RuntimeConfig, RuntimeError}; pub use trust::{ComponentDigest, TrustDecision, TrustStore}; - diff --git a/crates/flowstate-extension/wit/extension.wit b/crates/flowstate-extension/wit/extension.wit new file mode 100644 index 00000000..ecbd158a --- /dev/null +++ b/crates/flowstate-extension/wit/extension.wit @@ -0,0 +1,20 @@ +package flowstate:extension@1.0.0; + +interface host { + record host-error { + code: string, + message: string, + } + + snapshot: func() -> result; + selection: func() -> result; + apply-edits: func(edits-json: string) -> result; + refresh-from-disk: func() -> result; + set-action-label: func(action-id: string, label: string) -> result<_, host-error>; + set-status: func(message: string); +} + +world extension { + import host; + export run: func(action-id: string) -> result<_, string>; +} From 8002fc3c0d27eefc73b26d2af839d33e6d8a335a Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:20:06 -0700 Subject: [PATCH 10/54] Add Wasmtime extension runtime --- crates/flowstate-extension/src/runtime.rs | 154 ++++++++++++++++++ .../src/runtime/host_state.rs | 35 ++++ 2 files changed, 189 insertions(+) create mode 100644 crates/flowstate-extension/src/runtime.rs create mode 100644 crates/flowstate-extension/src/runtime/host_state.rs diff --git a/crates/flowstate-extension/src/runtime.rs b/crates/flowstate-extension/src/runtime.rs new file mode 100644 index 00000000..e1edb7b0 --- /dev/null +++ b/crates/flowstate-extension/src/runtime.rs @@ -0,0 +1,154 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use parking_lot::Mutex; +use thiserror::Error; +use wasmtime::component::{Component, Linker}; +use wasmtime::{Config, Engine, Store, StoreLimitsBuilder}; +use wasmtime_wasi::p2::pipe::MemoryOutputPipe; +use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder}; + +mod host_state; +use host_state::State; + +wasmtime::component::bindgen!({ path: "wit", world: "extension" }); + +const OUTPUT_LIMIT: usize = 10 * 1024 * 1024; + +#[derive(Clone, Debug)] +pub struct RuntimeConfig { + pub memory_limit: usize, + pub output_limit: usize, + pub allow_network: bool, +} + +impl Default for RuntimeConfig { + fn default() -> Self { Self { memory_limit: 1024 * 1024 * 1024, output_limit: OUTPUT_LIMIT, allow_network: true } } +} + +#[derive(Clone, Debug)] +pub struct Invocation { + pub component: PathBuf, + pub extension_root: PathBuf, + pub data_root: PathBuf, + pub document_root: Option, + pub action_id: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct InvocationOutput { + pub stdout: Vec, + pub stderr: Vec, +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +#[error("{code}: {message}")] +pub struct HostError { pub code: String, pub message: String } + +pub trait ExtensionHost: Send + 'static { + fn snapshot(&mut self) -> Result; + fn selection(&mut self) -> Result; + fn apply_edits(&mut self, edits_json: &str) -> Result; + fn refresh_from_disk(&mut self) -> Result; + fn set_action_label(&mut self, action_id: &str, label: &str) -> Result<(), HostError>; + fn set_status(&mut self, message: &str); +} + +#[derive(Debug, Error)] +pub enum RuntimeError { + #[error("extension is already running")] + AlreadyRunning, + #[error("extension was cancelled")] + Cancelled, + #[error(transparent)] + Wasmtime(#[from] anyhow::Error), +} + +#[derive(Clone)] +pub struct CancellationHandle { cancelled: Arc, engine: Engine } + +impl CancellationHandle { + pub fn cancel(&self) { self.cancelled.store(true, Ordering::Release); self.engine.increment_epoch(); } +} + +pub struct Runtime { + engine: Engine, + config: RuntimeConfig, + running: Mutex>, +} + +impl Runtime { + pub fn new(config: RuntimeConfig) -> Result { + let mut engine_config = Config::new(); + engine_config.wasm_component_model(true).epoch_interruption(true); + Ok(Self { engine: Engine::new(&engine_config)?, config, running: Mutex::new(std::collections::HashSet::new()) }) + } + + pub fn cancellation_handle(&self) -> CancellationHandle { + CancellationHandle { cancelled: Arc::new(AtomicBool::new(false)), engine: self.engine.clone() } + } + + pub fn invoke(&self, extension_id: &str, invocation: &Invocation, host: H, cancellation: &CancellationHandle) -> Result { + let _guard = RunningGuard::acquire(&self.running, extension_id)?; + if cancellation.cancelled.load(Ordering::Acquire) { return Err(RuntimeError::Cancelled); } + let component = Component::from_file(&self.engine, &invocation.component)?; + let stdout = MemoryOutputPipe::new(self.config.output_limit); + let stderr = MemoryOutputPipe::new(self.config.output_limit); + let wasi = build_wasi(invocation, &self.config, &stdout, &stderr)?; + let limits = StoreLimitsBuilder::new().memory_size(self.config.memory_limit).build(); + let mut store = Store::new(&self.engine, State::new(wasi, limits, host)); + store.limiter(|state| &mut state.limits); + store.set_epoch_deadline(1); + let cancelled = Arc::clone(&cancellation.cancelled); + store.epoch_deadline_callback(move |_| { + if cancelled.load(Ordering::Acquire) { + Err(anyhow::anyhow!("extension cancelled")) + } else { + Ok(wasmtime::UpdateDeadline::Continue(1)) + } + }); + let mut linker = Linker::new(&self.engine); + wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?; + flowstate::extension::host::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |state| state)?; + let instance = Extension::instantiate(&mut store, &component, &linker)?; + let result = instance.call_run(&mut store, &invocation.action_id)?; + if cancellation.cancelled.load(Ordering::Acquire) { return Err(RuntimeError::Cancelled); } + result.map_err(|message| RuntimeError::Wasmtime(anyhow::anyhow!(message)))?; + Ok(InvocationOutput { stdout: stdout.contents().to_vec(), stderr: stderr.contents().to_vec() }) + } +} + +fn build_wasi( + invocation: &Invocation, + config: &RuntimeConfig, + stdout: &MemoryOutputPipe, + stderr: &MemoryOutputPipe, +) -> anyhow::Result { + std::fs::create_dir_all(&invocation.data_root)?; + let mut builder = WasiCtxBuilder::new(); + builder.stdout(stdout.clone()).stderr(stderr.clone()).allow_blocking_current_thread(true); + builder.preopened_dir(&invocation.extension_root, "/extension", DirPerms::READ, FilePerms::READ)?; + builder.preopened_dir(&invocation.data_root, "/data", DirPerms::all(), FilePerms::all())?; + if let Some(document_root) = &invocation.document_root { + builder.preopened_dir(document_root, "/document", DirPerms::all(), FilePerms::all())?; + } + if config.allow_network { builder.inherit_network(); } + Ok(builder.build()) +} + +struct RunningGuard<'a> { + running: &'a Mutex>, + extension_id: String, +} + +impl<'a> RunningGuard<'a> { + fn acquire(running: &'a Mutex>, extension_id: &str) -> Result { + if !running.lock().insert(extension_id.to_owned()) { return Err(RuntimeError::AlreadyRunning); } + Ok(Self { running, extension_id: extension_id.to_owned() }) + } +} + +impl Drop for RunningGuard<'_> { + fn drop(&mut self) { self.running.lock().remove(&self.extension_id); } +} diff --git a/crates/flowstate-extension/src/runtime/host_state.rs b/crates/flowstate-extension/src/runtime/host_state.rs new file mode 100644 index 00000000..01b32f18 --- /dev/null +++ b/crates/flowstate-extension/src/runtime/host_state.rs @@ -0,0 +1,35 @@ +use wasmtime::StoreLimits; +use wasmtime::component::ResourceTable; +use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; + +use super::{ExtensionHost, HostError, flowstate}; + +pub(super) struct State { + wasi: WasiCtx, + table: ResourceTable, + pub(super) limits: StoreLimits, + host: H, +} + +impl State { + pub(super) fn new(wasi: WasiCtx, limits: StoreLimits, host: H) -> Self { + Self { wasi, table: ResourceTable::new(), limits, host } + } +} + +impl WasiView for State { + fn ctx(&mut self) -> WasiCtxView<'_> { WasiCtxView { ctx: &mut self.wasi, table: &mut self.table } } +} + +impl flowstate::extension::host::Host for State { + fn snapshot(&mut self) -> Result { self.host.snapshot().map_err(Into::into) } + fn selection(&mut self) -> Result { self.host.selection().map_err(Into::into) } + fn apply_edits(&mut self, edits_json: String) -> Result { self.host.apply_edits(&edits_json).map_err(Into::into) } + fn refresh_from_disk(&mut self) -> Result { self.host.refresh_from_disk().map_err(Into::into) } + fn set_action_label(&mut self, action_id: String, label: String) -> Result<(), flowstate::extension::host::HostError> { self.host.set_action_label(&action_id, &label).map_err(Into::into) } + fn set_status(&mut self, message: String) { self.host.set_status(&message); } +} + +impl From for flowstate::extension::host::HostError { + fn from(error: HostError) -> Self { Self { code: error.code, message: error.message } } +} From 122f59256ef23cdefa128f40964ceb638607e250 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:20:38 -0700 Subject: [PATCH 11/54] Reset editor lifecycle on disk refresh --- .../src/rich_text/editor/lifecycle.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs b/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs index 315f3d34..6972db5d 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/lifecycle.rs @@ -278,6 +278,36 @@ impl RichTextEditor { self.after_text_mutation(cx); } + /// Installs a freshly read on-disk document as the new saved baseline. + /// + /// Unlike an edit, this intentionally discards history and does not create a + /// recovery write. The caller remains responsible for dirty-document + /// confirmation before invoking this method. + pub fn replace_document_from_disk(&mut self, mut document: Document, cx: &mut Context) { + self.clear_document_equation_caches(); + rebuild_document_sections(&mut document); + reconcile_document_ids(&mut document); + self.document = document; + self.identity_map = DocumentIdentityMap::new(&self.document); + self.selection.anchor = clamped_extension_offset(&self.document, self.selection.anchor); + self.selection.head = clamped_extension_offset(&self.document, self.selection.head); + self.selected_block = None; + self.pending_styles = None; + self.undo_stack.clear(); + self.redo_stack.clear(); + self.last_collaboration_edit = None; + self.edit_generation = 0; + self.saved_generation = 0; + self.next_edit_generation = 1; + self.last_recovery_generation = 0; + self.recovery_write_pending = false; + self.save_status = SaveStatus::Saved; + self.invalidate_document_layout_caches(); + self.pending_scroll_head_after_layout = true; + self.reset_caret_blink(cx); + cx.notify(); + } + fn apply_canonical_operation(&mut self, operation: &CanonicalOperation) { match operation { CanonicalOperation::InsertText { From 89d0b55b662b9ebe5caf68972bd35f7d1dbb04dc Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:20:38 -0700 Subject: [PATCH 12/54] Test extension edit validation --- .../src/rich_text/editor/extension_api.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs index e7582cc8..5e169f7d 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs @@ -181,3 +181,63 @@ fn clamped_extension_offset(document: &Document, offset: DocumentOffset) -> Docu byte: offset.byte.min(document.paragraphs.get(paragraph).map_or(0, paragraph_text_len)), } } + +#[cfg(test)] +mod extension_api_tests { + use super::*; + + fn text_document(text: &str) -> Document { + document_from_input( + DocumentTheme::default(), + vec![InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![InputRun { + text: text.to_owned(), + styles: RunStyles::default(), + }], + }], + ) + } + + #[test] + fn rich_text_replacement_is_applied_to_a_candidate_document() { + let mut document = text_document("alpha omega"); + let styles = RunStyles::default().with(RunStyle::Semantic(2)); + let edit = ExtensionDocumentEdit::ReplaceText { + range: DocumentOffset { paragraph: 0, byte: 6 }..DocumentOffset { paragraph: 0, byte: 11 }, + fragment: RichClipboardFragment { + format: RICH_TEXT_CLIPBOARD_FORMAT.to_owned(), + paragraphs: vec![InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![InputRun { + text: "beta".to_owned(), + styles, + }], + }], + blocks: Vec::new(), + assets: Vec::new(), + }, + }; + + apply_extension_edit(&mut document, &edit).unwrap(); + assert_eq!(paragraph_text(&document, 0), "alpha beta"); + assert_eq!(document.paragraphs[0].runs.last().unwrap().styles, styles); + } + + #[test] + fn text_replacement_rejects_non_utf8_boundary() { + let mut document = text_document("café"); + let edit = ExtensionDocumentEdit::ReplaceText { + range: DocumentOffset { paragraph: 0, byte: 4 }..DocumentOffset { paragraph: 0, byte: 5 }, + fragment: RichClipboardFragment { + format: RICH_TEXT_CLIPBOARD_FORMAT.to_owned(), + paragraphs: Vec::new(), + blocks: Vec::new(), + assets: Vec::new(), + }, + }; + + assert_eq!(apply_extension_edit(&mut document, &edit), Err(ExtensionEditError::InvalidRange)); + assert_eq!(paragraph_text(&document, 0), "café"); + } +} From 1d5d0659daed90dcd71e283d77c4a9465c641968 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:20:51 -0700 Subject: [PATCH 13/54] Fix extension API imports --- crates/gpui-flowtext/src/rich_text/editor/extension_api.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs index 5e169f7d..f0e8f705 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs @@ -1,6 +1,4 @@ -use std::{fmt, ops::Range, sync::Arc}; - -use super::*; +use std::fmt; #[derive(Clone, Debug)] pub struct ExtensionDocumentSnapshot { From fe0c76abb8c5b92734def59bcdaf1bf3907bb775 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:21:27 -0700 Subject: [PATCH 14/54] Test extension validation and coordination --- crates/flowstate-extension/src/runtime.rs | 25 +++++++++ crates/flowstate-extension/tests/manifest.rs | 57 ++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 crates/flowstate-extension/tests/manifest.rs diff --git a/crates/flowstate-extension/src/runtime.rs b/crates/flowstate-extension/src/runtime.rs index e1edb7b0..0a0f2cd1 100644 --- a/crates/flowstate-extension/src/runtime.rs +++ b/crates/flowstate-extension/src/runtime.rs @@ -152,3 +152,28 @@ impl<'a> RunningGuard<'a> { impl Drop for RunningGuard<'_> { fn drop(&mut self) { self.running.lock().remove(&self.extension_id); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_actions_per_extension() { + let running = Mutex::new(std::collections::HashSet::new()); + let first = RunningGuard::acquire(&running, "com.example.one").unwrap(); + assert!(matches!(RunningGuard::acquire(&running, "com.example.one"), Err(RuntimeError::AlreadyRunning))); + assert!(RunningGuard::acquire(&running, "com.example.two").is_ok()); + drop(first); + assert!(RunningGuard::acquire(&running, "com.example.one").is_ok()); + } + + #[test] + fn cancellation_is_token_scoped() { + let runtime = Runtime::new(RuntimeConfig::default()).unwrap(); + let cancelled = runtime.cancellation_handle(); + let other = runtime.cancellation_handle(); + cancelled.cancel(); + assert!(cancelled.cancelled.load(Ordering::Acquire)); + assert!(!other.cancelled.load(Ordering::Acquire)); + } +} diff --git a/crates/flowstate-extension/tests/manifest.rs b/crates/flowstate-extension/tests/manifest.rs new file mode 100644 index 00000000..9a4931e9 --- /dev/null +++ b/crates/flowstate-extension/tests/manifest.rs @@ -0,0 +1,57 @@ +use std::fs; + +use flowstate_extension::{ComponentDigest, ManifestError, TrustDecision, TrustStore, discover_extensions}; + +fn manifest(id: &str, component: &str) -> String { + format!(r#" +manifest_version = 1 +id = "{id}" +name = "Test" +version = "1.0.0" +component = "{component}" + +[[actions]] +id = "run" +label = "Run" +"#) +} + +#[test] +fn discovers_valid_extensions_and_reports_invalid_ones() { + let directory = tempfile::tempdir().unwrap(); + let valid = directory.path().join("valid"); + fs::create_dir(&valid).unwrap(); + fs::write(valid.join("extension.toml"), manifest("com.example.valid", "extension.wasm")).unwrap(); + fs::write(valid.join("extension.wasm"), b"component").unwrap(); + let invalid = directory.path().join("invalid"); + fs::create_dir(&invalid).unwrap(); + fs::write(invalid.join("extension.toml"), manifest("bad id", "missing.wasm")).unwrap(); + + let result = discover_extensions(directory.path()); + assert_eq!(result.extensions.len(), 1); + assert_eq!(result.extensions[0].manifest.id, "com.example.valid"); + assert_eq!(result.issues.len(), 1); +} + +#[test] +fn rejects_duplicate_actions_and_path_traversal() { + let duplicate = format!("{}\n[[actions]]\nid = \"run\"\nlabel = \"Again\"", manifest("com.example.test", "extension.wasm")); + assert_eq!(flowstate_extension::ExtensionManifest::parse(&duplicate), Err(ManifestError::DuplicateAction("run".to_owned()))); + let traversal = manifest("com.example.test", "../extension.wasm"); + assert_eq!(flowstate_extension::ExtensionManifest::parse(&traversal), Err(ManifestError::InvalidComponentPath)); +} + +#[test] +fn component_changes_require_fresh_approval() { + let directory = tempfile::tempdir().unwrap(); + let component = directory.path().join("extension.wasm"); + fs::write(&component, b"first").unwrap(); + let first = ComponentDigest::from_file(&component).unwrap(); + let mut trust = TrustStore::default(); + assert_eq!(trust.decision("com.example.test", &first), TrustDecision::ApprovalRequired); + trust.approve("com.example.test", first.clone()); + assert_eq!(trust.decision("com.example.test", &first), TrustDecision::Trusted); + fs::write(&component, b"second").unwrap(); + let second = ComponentDigest::from_file(&component).unwrap(); + assert_eq!(trust.decision("com.example.test", &second), TrustDecision::ApprovalRequired); +} From 8bd17e9403382939e3e425a8ad23f93ff6139ec0 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:21:34 -0700 Subject: [PATCH 15/54] Render extension action panel --- .../src/workspace/extensions_tool.rs | 176 +++++++++++++++++- .../src/workspace/workspace/documents.rs | 1 + 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/crates/flowstate/src/workspace/extensions_tool.rs b/crates/flowstate/src/workspace/extensions_tool.rs index b3870b55..27aed326 100644 --- a/crates/flowstate/src/workspace/extensions_tool.rs +++ b/crates/flowstate/src/workspace/extensions_tool.rs @@ -1,8 +1,182 @@ -use gpui::{Context, PromptButton, PromptLevel, SharedString, Window}; +use gpui::{AnyElement, Context, IntoElement, PromptButton, PromptLevel, SharedString, Window, div, prelude::*, px}; +use gpui_component::{ + ActiveTheme as _, Icon, IconName, Sizable, + button::{Button, ButtonVariants}, + collapsible::Collapsible, + h_flex, v_flex, +}; +use super::super::extensions_panel::{ExtensionRunState, ExtensionView}; use super::Workspace; impl Workspace { + pub(super) fn render_extensions_panel(&self, cx: &mut Context) -> AnyElement { + let extensions = self.extensions.extensions().to_vec(); + let body = if let Some(error) = self.extensions.error() { + self.render_extensions_message(error.clone(), cx) + } else if extensions.is_empty() { + self.render_extensions_message("No extensions installed".into(), cx) + } else { + v_flex() + .w_full() + .p_2() + .gap_2() + .children(extensions.iter().map(|extension| self.render_extension_card(extension, cx))) + .into_any_element() + }; + + v_flex() + .size_full() + .min_h_0() + .bg(cx.theme().background) + .border_l(super::APP_CHROME_BORDER_WIDTH) + .border_color(cx.theme().border) + .child( + h_flex() + .h(px(34.0)) + .flex_none() + .items_center() + .gap_2() + .px_2() + .border_b_1() + .border_color(cx.theme().border) + .child( + Button::new("extensions-panel-tool") + .icon(Icon::new(IconName::Bot).text_color(cx.theme().sidebar_primary)) + .xsmall() + .ghost() + .selected(true) + .tooltip("Close extensions panel") + .on_click(cx.listener(|workspace, _, _, cx| { + workspace.toggle_toolkit_tool(super::ToolkitTool::Extensions, cx); + })), + ) + .child(div().flex_1().text_sm().font_weight(gpui::FontWeight::SEMIBOLD).child("Extensions")) + .child( + Button::new("reload-extensions") + .icon(Icon::new(IconName::Redo2).text_color(cx.theme().muted_foreground)) + .xsmall() + .ghost() + .tooltip("Reload extensions") + .on_click(cx.listener(|workspace, _, _, cx| workspace.reload_extensions(cx))), + ) + .child( + Button::new("hide-extensions-rail") + .icon(Icon::new(IconName::PanelRightClose).text_color(cx.theme().muted_foreground)) + .xsmall() + .ghost() + .tooltip("Hide toolkit") + .on_click(cx.listener(|workspace, _, _, cx| workspace.toggle_toolkit(cx))), + ), + ) + .child(div().flex_1().min_h_0().overflow_y_scroll().child(body)) + .into_any_element() + } + + fn render_extensions_message(&self, message: SharedString, cx: &mut Context) -> AnyElement { + div() + .h(px(120.0)) + .flex() + .items_center() + .justify_center() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(message) + .into_any_element() + } + + fn render_extension_card(&self, extension: &ExtensionView, cx: &mut Context) -> AnyElement { + let extension_id = extension.id.clone(); + let open = self.expanded_extensions.contains(&extension_id); + let state = self.extensions.state(extension_id.as_ref()); + let running = matches!(state, ExtensionRunState::Running { .. }); + let toggle_id = extension_id.clone(); + let chevron = if open { IconName::ChevronDown } else { IconName::ChevronRight }; + let actions = extension.actions.iter().map(|action| { + let action_id = action.id.clone(); + let target_extension = extension_id.clone(); + let label = self.extensions.label(extension_id.as_ref(), action); + let action_running = matches!( + &state, + ExtensionRunState::Running { action_id: running_action } if running_action == &action.id + ); + Button::new(format!("extension-action-{}-{}", extension_id, action.id)) + .label(label) + .w_full() + .loading(action_running) + .disabled(running || (action.requires_document && self.active_editor.is_none())) + .on_click(cx.listener(move |workspace, _, window, cx| { + workspace.request_extension_action(target_extension.clone(), action_id.clone(), window, cx); + })) + }); + let status = match state { + ExtensionRunState::Idle => None, + ExtensionRunState::Running { .. } => Some("Running…".into()), + ExtensionRunState::Failed(message) => Some(message), + ExtensionRunState::Cancelled => Some("Cancelled".into()), + }; + let output = self.extensions.output(extension_id.as_ref()).cloned(); + let cancel_id = extension_id.clone(); + + Collapsible::new() + .open(open) + .w_full() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .child( + Button::new(format!("extension-group-{extension_id}")) + .icon(Icon::new(chevron).text_color(cx.theme().muted_foreground)) + .label(extension.name.clone()) + .w_full() + .ghost() + .on_click(cx.listener(move |workspace, _, _, cx| { + workspace.toggle_extension_group(toggle_id.clone(), cx); + })), + ) + .content( + v_flex() + .w_full() + .gap_2() + .px_2() + .pb_2() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{} · {}", extension.id, extension.version)), + ) + .children(actions) + .when(running, |this| { + this.child( + Button::new(format!("cancel-extension-{extension_id}")) + .label("Cancel") + .outline() + .w_full() + .on_click(cx.listener(move |workspace, _, _, cx| { + workspace.cancel_extension(cancel_id.clone(), cx); + })), + ) + }) + .when_some(status, |this, status| { + this.child(div().text_xs().text_color(cx.theme().muted_foreground).child(status)) + }) + .when_some(output, |this, output| { + this.child( + div() + .max_h(px(160.0)) + .overflow_y_scroll() + .rounded(cx.theme().radius) + .bg(cx.theme().muted) + .p_2() + .text_xs() + .child(output), + ) + }), + ) + .into_any_element() + } + fn reload_extensions(&mut self, cx: &mut Context) { self.extensions.reload(); self diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index 0f3cd1e4..bd0bc83b 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -121,6 +121,7 @@ impl Workspace { }; this.refresh_recent_document_previews(cx); + this.extensions.reload(); if let Some(root) = load_tub_root() { this.load_tub_root(root, cx); From e00568c41460250ec32461da3b04229f07caa00b Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:22:02 -0700 Subject: [PATCH 16/54] Box replacement documents in extension edits --- .../src/rich_text/editor/extension_api.rs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs index f0e8f705..8b487596 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs @@ -38,7 +38,7 @@ pub enum ExtensionDocumentEdit { cell_ix: usize, blocks: Vec, }, - ReplaceDocument(Document), + ReplaceDocument(Box), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -53,17 +53,17 @@ pub enum ExtensionEditError { } impl fmt::Display for ExtensionEditError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::StaleGeneration { expected, actual } => write!(formatter, "document generation changed (expected {expected}, found {actual})"), - Self::ReadOnly => formatter.write_str("document is read-only"), - Self::InvalidRange => formatter.write_str("text range is outside the document or not on UTF-8 boundaries"), - Self::InvalidBlock(block_ix) => write!(formatter, "block index {block_ix} is outside the document"), - Self::NotATable(block_ix) => write!(formatter, "block {block_ix} is not a table"), + Self::StaleGeneration { expected, actual } => write!(f, "document generation changed (expected {expected}, found {actual})"), + Self::ReadOnly => f.write_str("document is read-only"), + Self::InvalidRange => f.write_str("text range is outside the document or not on UTF-8 boundaries"), + Self::InvalidBlock(block_ix) => write!(f, "block index {block_ix} is outside the document"), + Self::NotATable(block_ix) => write!(f, "block {block_ix} is not a table"), Self::InvalidTableCell { block_ix, row_ix, cell_ix } => { - write!(formatter, "table cell {block_ix}:{row_ix}:{cell_ix} does not exist") + write!(f, "table cell {block_ix}:{row_ix}:{cell_ix} does not exist") }, - Self::InvalidDocument => formatter.write_str("replacement would leave an invalid document"), + Self::InvalidDocument => f.write_str("replacement would leave an invalid document"), } } } @@ -167,7 +167,7 @@ fn apply_extension_edit(document: &mut Document, edit: &ExtensionDocumentEdit) - cell.blocks.clone_from(blocks); table.version = table.version.wrapping_add(1); }, - ExtensionDocumentEdit::ReplaceDocument(replacement) => *document = replacement.clone(), + ExtensionDocumentEdit::ReplaceDocument(replacement) => *document = (**replacement).clone(), } Ok(()) } From 716d50c84dd36b4fd7e488eaedfee0c33b8a4f25 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:24:03 -0700 Subject: [PATCH 17/54] Satisfy extension crate test lint --- crates/flowstate-extension/tests/manifest.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/flowstate-extension/tests/manifest.rs b/crates/flowstate-extension/tests/manifest.rs index 9a4931e9..78327f54 100644 --- a/crates/flowstate-extension/tests/manifest.rs +++ b/crates/flowstate-extension/tests/manifest.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +mod tests { use std::fs; use flowstate_extension::{ComponentDigest, ManifestError, TrustDecision, TrustStore, discover_extensions}; @@ -55,3 +57,4 @@ fn component_changes_require_fresh_approval() { let second = ComponentDigest::from_file(&component).unwrap(); assert_eq!(trust.decision("com.example.test", &second), TrustDecision::ApprovalRequired); } +} From c5a35e5309a54c69c5c56bef2ef98829c232d309 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:25:26 -0700 Subject: [PATCH 18/54] Enable WASI HTTP for trusted extensions --- Cargo.lock | 96 +++++++++++++++++-- crates/flowstate-extension/Cargo.toml | 1 + crates/flowstate-extension/src/runtime.rs | 6 +- .../src/runtime/host_state.rs | 9 +- 4 files changed, 102 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 75a5d91c..30582414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2869,6 +2869,7 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", "wasmtime", "wasmtime-wasi", + "wasmtime-wasi-http", ] [[package]] @@ -4247,6 +4248,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -4263,10 +4265,10 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", + "rustls 0.23.40", "rustls-native-certs", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -6975,7 +6977,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.2", - "rustls", + "rustls 0.23.40", "socket2", "thiserror 2.0.18", "tokio", @@ -6995,7 +6997,7 @@ dependencies = [ "rand 0.9.4", "ring", "rustc-hash 2.1.2", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -7757,6 +7759,20 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.40" @@ -7766,7 +7782,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -7802,6 +7818,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -9214,13 +9241,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.40", "tokio", ] @@ -10725,6 +10763,30 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "wasmtime-wasi-http" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "570346788aba8c1196829ae922f766ebf87b2882facabd618d7946670332d081" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "rustls 0.22.4", + "tokio", + "tokio-rustls 0.25.0", + "tracing", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-io", + "webpki-roots 0.26.11", +] + [[package]] name = "wasmtime-wasi-io" version = "38.0.4" @@ -10886,6 +10948,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -12040,7 +12120,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.40", "rustls-native-certs", "rustls-pemfile", "rustls-pki-types", @@ -12050,7 +12130,7 @@ dependencies = [ "sync_wrapper", "system-configuration", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-socks", "tokio-util", "tower", diff --git a/crates/flowstate-extension/Cargo.toml b/crates/flowstate-extension/Cargo.toml index 308023a5..5c4e2f22 100644 --- a/crates/flowstate-extension/Cargo.toml +++ b/crates/flowstate-extension/Cargo.toml @@ -15,6 +15,7 @@ thiserror = "2.0.18" toml.workspace = true wasmtime = { version = "38", features = ["component-model", "cranelift", "runtime"] } wasmtime-wasi = "38" +wasmtime-wasi-http = "38" [lints] workspace = true diff --git a/crates/flowstate-extension/src/runtime.rs b/crates/flowstate-extension/src/runtime.rs index 0a0f2cd1..3585f8d7 100644 --- a/crates/flowstate-extension/src/runtime.rs +++ b/crates/flowstate-extension/src/runtime.rs @@ -109,7 +109,11 @@ impl Runtime { } }); let mut linker = Linker::new(&self.engine); - wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?; + if self.config.allow_network { + wasmtime_wasi_http::add_to_linker_sync(&mut linker)?; + } else { + wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?; + } flowstate::extension::host::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |state| state)?; let instance = Extension::instantiate(&mut store, &component, &linker)?; let result = instance.call_run(&mut store, &invocation.action_id)?; diff --git a/crates/flowstate-extension/src/runtime/host_state.rs b/crates/flowstate-extension/src/runtime/host_state.rs index 01b32f18..f7f54f66 100644 --- a/crates/flowstate-extension/src/runtime/host_state.rs +++ b/crates/flowstate-extension/src/runtime/host_state.rs @@ -1,11 +1,13 @@ use wasmtime::StoreLimits; use wasmtime::component::ResourceTable; use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; +use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpView}; use super::{ExtensionHost, HostError, flowstate}; pub(super) struct State { wasi: WasiCtx, + http: WasiHttpCtx, table: ResourceTable, pub(super) limits: StoreLimits, host: H, @@ -13,10 +15,15 @@ pub(super) struct State { impl State { pub(super) fn new(wasi: WasiCtx, limits: StoreLimits, host: H) -> Self { - Self { wasi, table: ResourceTable::new(), limits, host } + Self { wasi, http: WasiHttpCtx::new(), table: ResourceTable::new(), limits, host } } } +impl WasiHttpView for State { + fn ctx(&mut self) -> &mut WasiHttpCtx { &mut self.http } + fn table(&mut self) -> &mut ResourceTable { &mut self.table } +} + impl WasiView for State { fn ctx(&mut self) -> WasiCtxView<'_> { WasiCtxView { ctx: &mut self.wasi, table: &mut self.table } } } From 72f37391d6a39fe046d2938201f2bf4ba19c5ecd Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:25:45 -0700 Subject: [PATCH 19/54] Fix extension panel integration --- .../src/workspace/extensions_panel/mod.rs | 2 +- .../src/workspace/extensions_panel/model.rs | 12 ++++++++---- crates/flowstate/src/workspace/extensions_tool.rs | 15 ++++++++------- .../src/workspace/workspace/documents.rs | 2 +- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/flowstate/src/workspace/extensions_panel/mod.rs b/crates/flowstate/src/workspace/extensions_panel/mod.rs index 0a53f864..88fbfee2 100644 --- a/crates/flowstate/src/workspace/extensions_panel/mod.rs +++ b/crates/flowstate/src/workspace/extensions_panel/mod.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use gpui::SharedString; pub use model::{ - ExtensionActionView, ExtensionPanelAdapter, ExtensionPanelController, ExtensionPanelEvent, ExtensionRunState, ExtensionView, + ExtensionPanelAdapter, ExtensionPanelController, ExtensionPanelEvent, ExtensionRunState, ExtensionView, }; struct DisconnectedExtensionAdapter; diff --git a/crates/flowstate/src/workspace/extensions_panel/model.rs b/crates/flowstate/src/workspace/extensions_panel/model.rs index a8b08c6a..d8c05e32 100644 --- a/crates/flowstate/src/workspace/extensions_panel/model.rs +++ b/crates/flowstate/src/workspace/extensions_panel/model.rs @@ -27,6 +27,7 @@ pub enum ExtensionRunState { } #[derive(Clone, Debug, PartialEq, Eq)] +#[allow(dead_code, reason = "runtime events are consumed once the Wasmtime adapter is connected")] pub enum ExtensionPanelEvent { Started { extension_id: SharedString, action_id: SharedString }, ActionLabel { extension_id: SharedString, action_id: SharedString, label: SharedString }, @@ -86,7 +87,7 @@ impl ExtensionPanelController { pub fn label(&self, extension_id: &str, action: &ExtensionActionView) -> SharedString { self .labels - .get(&(extension_id.into(), action.id.clone())) + .get(&(SharedString::from(extension_id.to_owned()), action.id.clone())) .cloned() .unwrap_or_else(|| action.label.clone()) } @@ -119,16 +120,19 @@ impl ExtensionPanelController { pub fn invoke(&mut self, extension_id: &str, action_id: &str) { self .states - .insert(extension_id.into(), ExtensionRunState::Running { action_id: action_id.into() }); + .insert( + SharedString::from(extension_id.to_owned()), + ExtensionRunState::Running { action_id: SharedString::from(action_id.to_owned()) }, + ); self.outputs.remove(extension_id); if let Err(error) = self.adapter.invoke(extension_id, action_id) { - self.states.insert(extension_id.into(), ExtensionRunState::Failed(error)); + self.states.insert(SharedString::from(extension_id.to_owned()), ExtensionRunState::Failed(error)); } } pub fn cancel(&mut self, extension_id: &str) { if let Err(error) = self.adapter.cancel(extension_id) { - self.states.insert(extension_id.into(), ExtensionRunState::Failed(error)); + self.states.insert(SharedString::from(extension_id.to_owned()), ExtensionRunState::Failed(error)); } } diff --git a/crates/flowstate/src/workspace/extensions_tool.rs b/crates/flowstate/src/workspace/extensions_tool.rs index 27aed326..c745bd0b 100644 --- a/crates/flowstate/src/workspace/extensions_tool.rs +++ b/crates/flowstate/src/workspace/extensions_tool.rs @@ -1,6 +1,6 @@ use gpui::{AnyElement, Context, IntoElement, PromptButton, PromptLevel, SharedString, Window, div, prelude::*, px}; use gpui_component::{ - ActiveTheme as _, Icon, IconName, Sizable, + ActiveTheme as _, Disableable, Icon, IconName, Selectable, Sizable, button::{Button, ButtonVariants}, collapsible::Collapsible, h_flex, v_flex, @@ -69,7 +69,7 @@ impl Workspace { .on_click(cx.listener(|workspace, _, _, cx| workspace.toggle_toolkit(cx))), ), ) - .child(div().flex_1().min_h_0().overflow_y_scroll().child(body)) + .child(div().id("extensions-panel-scroll").flex_1().min_h_0().overflow_y_scroll().child(body)) .into_any_element() } @@ -100,7 +100,7 @@ impl Workspace { &state, ExtensionRunState::Running { action_id: running_action } if running_action == &action.id ); - Button::new(format!("extension-action-{}-{}", extension_id, action.id)) + Button::new(SharedString::from(format!("extension-action-{}-{}", extension_id, action.id))) .label(label) .w_full() .loading(action_running) @@ -109,10 +109,10 @@ impl Workspace { workspace.request_extension_action(target_extension.clone(), action_id.clone(), window, cx); })) }); - let status = match state { + let status = match &state { ExtensionRunState::Idle => None, ExtensionRunState::Running { .. } => Some("Running…".into()), - ExtensionRunState::Failed(message) => Some(message), + ExtensionRunState::Failed(message) => Some(message.clone()), ExtensionRunState::Cancelled => Some("Cancelled".into()), }; let output = self.extensions.output(extension_id.as_ref()).cloned(); @@ -125,7 +125,7 @@ impl Workspace { .border_1() .border_color(cx.theme().border) .child( - Button::new(format!("extension-group-{extension_id}")) + Button::new(SharedString::from(format!("extension-group-{extension_id}"))) .icon(Icon::new(chevron).text_color(cx.theme().muted_foreground)) .label(extension.name.clone()) .w_full() @@ -149,7 +149,7 @@ impl Workspace { .children(actions) .when(running, |this| { this.child( - Button::new(format!("cancel-extension-{extension_id}")) + Button::new(SharedString::from(format!("cancel-extension-{extension_id}"))) .label("Cancel") .outline() .w_full() @@ -164,6 +164,7 @@ impl Workspace { .when_some(output, |this, output| { this.child( div() + .id(SharedString::from(format!("extension-output-{extension_id}"))) .max_h(px(160.0)) .overflow_y_scroll() .rounded(cx.theme().radius) diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index bd0bc83b..f00fa48d 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -57,7 +57,7 @@ impl Workspace { outline_collapsed: false, toolkit_collapsed: false, active_toolkit_tool: None, - extensions: super::super::extensions_panel::disconnected_controller(), + extensions: crate::workspace::extensions_panel::disconnected_controller(), expanded_extensions: HashSet::new(), recent_documents: load_recent_documents(), recent_document_previews: HashMap::new(), From 33abc9257fb542306d1075b854abde9c9509042a Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:27:05 -0700 Subject: [PATCH 20/54] Document extension authoring and capabilities --- docs/extensions.md | 95 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/extensions.md diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 00000000..3c1a54ab --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,95 @@ +# Flowstate extensions + +Flowstate extensions are trusted WebAssembly components executed by Wasmtime. +An extension may be written in any language that can produce a WebAssembly +Component Model component, including a component that bundles its own Python or +JavaScript interpreter. + +Flowstate does not compile Python or JavaScript for extension authors. The +installed artifact must already implement the `flowstate:extension@1.0.0` WIT +world defined in `crates/flowstate-extension/wit/extension.wit`. + +## Installation + +Install each extension in its own directory: + +```text +~/.local/share/flowstate/extensions/com.example.rewriter/ + extension.toml + extension.wasm +``` + +On non-Linux platforms the base directory follows the operating system's normal +user data directory convention. + +An extension manifest has this shape: + +```toml +manifest_version = 1 +id = "com.example.rewriter" +name = "Rewriter" +version = "1.0.0" +component = "extension.wasm" + +[[actions]] +id = "rewrite-selection" +label = "Rewrite selection" +requires_document = true + +[[actions]] +id = "open-dashboard" +label = "Open dashboard" +requires_document = false +``` + +The component path is relative to the extension directory and cannot escape it. +Extension and action IDs must be dot-separated identifiers containing ASCII +letters, numbers, `-`, or `_`. + +## UI and activation + +Flowstate shows one Extensions icon in the existing side-panel rail. The panel +contains one collapsible section per installed extension and renders every +manifest action as a button. Pressing a button calls the component's +`run(action-id)` export. + +Flowstate asks for confirmation the first time an extension runs. Approval is +bound to the component digest, so replacing the component prompts again. +Extensions are arbitrary trusted code within the WASI capabilities described +below; only install components you trust. + +An extension can update its button labels for the current Flowstate session by +calling `set-action-label`. Labels return to their manifest values after a +restart or extension reload. + +## Runtime capabilities + +Every action gets a fresh component instance. Flowstate caches compiled +components, permits one running action per extension, and allows actions from +different extensions to run concurrently. The panel exposes cancellation and +captures bounded stdout and stderr output. + +The guest receives these pre-opened directories: + +- `/extension`: the installation directory, read-only. +- `/data`: persistent extension-specific data, read/write. +- `/document`: the active saved document's directory, read/write, only when a + document-required action is invoked. + +The runtime provides WASI clocks, randomness, HTTP, and sockets. It does not +inherit Flowstate's environment or provide native process spawning. + +## Document API + +The imported host interface provides JSON snapshots and generation-checked edit +batches. A snapshot contains the current caret or selection, selected text, rich +document blocks, and an edit generation. Submit that generation with edits; +Flowstate rejects stale offsets if the document changed in the meantime. + +Accepted edits are validated as a complete batch and committed as one undoable +editor operation. They pass through the normal editor pipeline so layout, +autosave, recovery, outline, and collaboration state remain consistent. + +`refresh-from-disk` reloads the active native document. If the document has +unsaved changes, Flowstate asks the user before discarding them. Untitled and +imported documents cannot be refreshed from disk. From 7c9a07dfe5e5c77e384ddf51f3edf0befcedd531 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:27:42 -0700 Subject: [PATCH 21/54] Cache compiled extension components by digest --- crates/flowstate-extension/src/runtime.rs | 37 +++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/flowstate-extension/src/runtime.rs b/crates/flowstate-extension/src/runtime.rs index 3585f8d7..02f6d7e3 100644 --- a/crates/flowstate-extension/src/runtime.rs +++ b/crates/flowstate-extension/src/runtime.rs @@ -9,6 +9,8 @@ use wasmtime::{Config, Engine, Store, StoreLimitsBuilder}; use wasmtime_wasi::p2::pipe::MemoryOutputPipe; use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder}; +use crate::ComponentDigest; + mod host_state; use host_state::State; @@ -76,13 +78,19 @@ pub struct Runtime { engine: Engine, config: RuntimeConfig, running: Mutex>, + components: Mutex>, } impl Runtime { pub fn new(config: RuntimeConfig) -> Result { let mut engine_config = Config::new(); engine_config.wasm_component_model(true).epoch_interruption(true); - Ok(Self { engine: Engine::new(&engine_config)?, config, running: Mutex::new(std::collections::HashSet::new()) }) + Ok(Self { + engine: Engine::new(&engine_config)?, + config, + running: Mutex::new(std::collections::HashSet::new()), + components: Mutex::new(std::collections::HashMap::new()), + }) } pub fn cancellation_handle(&self) -> CancellationHandle { @@ -92,7 +100,7 @@ impl Runtime { pub fn invoke(&self, extension_id: &str, invocation: &Invocation, host: H, cancellation: &CancellationHandle) -> Result { let _guard = RunningGuard::acquire(&self.running, extension_id)?; if cancellation.cancelled.load(Ordering::Acquire) { return Err(RuntimeError::Cancelled); } - let component = Component::from_file(&self.engine, &invocation.component)?; + let component = self.load_component(&invocation.component)?; let stdout = MemoryOutputPipe::new(self.config.output_limit); let stderr = MemoryOutputPipe::new(self.config.output_limit); let wasi = build_wasi(invocation, &self.config, &stdout, &stderr)?; @@ -121,6 +129,17 @@ impl Runtime { result.map_err(|message| RuntimeError::Wasmtime(anyhow::anyhow!(message)))?; Ok(InvocationOutput { stdout: stdout.contents().to_vec(), stderr: stderr.contents().to_vec() }) } + + fn load_component(&self, path: &std::path::Path) -> Result { + let digest = ComponentDigest::from_file(path).map_err(anyhow::Error::from)?; + let key = (path.to_path_buf(), digest); + if let Some(component) = self.components.lock().get(&key).cloned() { return Ok(component); } + let component = Component::from_file(&self.engine, path)?; + let mut components = self.components.lock(); + components.retain(|(cached_path, _), _| cached_path != path); + components.insert(key, component.clone()); + Ok(component) + } } fn build_wasi( @@ -180,4 +199,18 @@ mod tests { assert!(cancelled.cancelled.load(Ordering::Acquire)); assert!(!other.cancelled.load(Ordering::Acquire)); } + + #[test] + fn caches_components_by_path_and_content() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("extension.wat"); + std::fs::write(&path, "(component)").unwrap(); + let runtime = Runtime::new(RuntimeConfig::default()).unwrap(); + runtime.load_component(&path).unwrap(); + runtime.load_component(&path).unwrap(); + assert_eq!(runtime.components.lock().len(), 1); + std::fs::write(&path, "(component (type (func)))").unwrap(); + runtime.load_component(&path).unwrap(); + assert_eq!(runtime.components.lock().len(), 1); + } } From ec4195894bc2674521e6a7a9b2a51e08a1ff23fd Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:28:00 -0700 Subject: [PATCH 22/54] Add extension JSON wire types --- .../src/rich_text/editor/extension_api.rs | 5 + .../src/rich_text/editor/extension_wire.rs | 113 ++++++++++++++++++ .../gpui-flowtext/src/rich_text/editor/mod.rs | 1 + 3 files changed, 119 insertions(+) create mode 100644 crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs index 8b487596..230c4eb7 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs @@ -13,6 +13,11 @@ pub struct ExtensionDocumentSnapshot { pub enum ExtensionSelection { Text(EditorSelection), Object { block_ix: usize }, + EquationSource { + block_ix: usize, + anchor: usize, + head: usize, + }, TableCell { block_ix: usize, row_ix: usize, diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs new file mode 100644 index 00000000..4111f772 --- /dev/null +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs @@ -0,0 +1,113 @@ +#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] +pub struct ExtensionWireOffset { + pub paragraph: usize, + pub byte: usize, +} + +impl From for DocumentOffset { + fn from(offset: ExtensionWireOffset) -> Self { + Self { + paragraph: offset.paragraph, + byte: offset.byte, + } + } +} + +impl From for ExtensionWireOffset { + fn from(offset: DocumentOffset) -> Self { + Self { + paragraph: offset.paragraph, + byte: offset.byte, + } + } +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ExtensionWireSelection { + Text { + anchor: ExtensionWireOffset, + head: ExtensionWireOffset, + }, + Object { + block_ix: usize, + }, + EquationSource { + block_ix: usize, + anchor: usize, + head: usize, + }, + TableCell { + block_ix: usize, + row_ix: usize, + cell_ix: usize, + anchor: usize, + head: usize, + }, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct ExtensionWireDocument { + pub blocks: Vec, + pub assets: Vec, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct ExtensionWireSnapshot { + pub generation: u64, + pub document: ExtensionWireDocument, + pub selection: ExtensionWireSelection, + pub selected_text: String, + pub selected_fragment: RichClipboardFragment, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct ExtensionWireEditRequest { + pub expected_generation: u64, + pub edits: Vec, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ExtensionWireEdit { + ReplaceText { + start: ExtensionWireOffset, + end: ExtensionWireOffset, + fragment: RichClipboardFragment, + }, + SpliceBlocks { + start: usize, + end: usize, + blocks: Vec, + #[serde(default)] + assets: Vec, + }, + ReplaceTableCell { + block_ix: usize, + row_ix: usize, + cell_ix: usize, + blocks: Vec, + }, +} + +#[derive(Clone, Copy, Debug, serde::Serialize)] +pub struct ExtensionWireEditResponse { + pub generation: u64, +} + +#[derive(Debug)] +pub enum ExtensionWireError { + Json(serde_json::Error), + Edit(ExtensionEditError), +} + +impl std::fmt::Display for ExtensionWireError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Json(error) => write!(f, "invalid extension JSON: {error}"), + Self::Edit(error) => error.fmt(f), + } + } +} + +impl std::error::Error for ExtensionWireError {} diff --git a/crates/gpui-flowtext/src/rich_text/editor/mod.rs b/crates/gpui-flowtext/src/rich_text/editor/mod.rs index deee5a96..989a30d3 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/mod.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/mod.rs @@ -947,6 +947,7 @@ pub struct RichTextEditor { include!("lifecycle.rs"); include!("extension_api.rs"); include!("extension_snapshot.rs"); +include!("extension_wire.rs"); include!("object_selection.rs"); include!("style_state.rs"); include!("search_highlights.rs"); From 838254b48c5b04b00999790dbf42f6c202df704b Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:28:45 -0700 Subject: [PATCH 23/54] Support structural block splices --- .../src/rich_text/editor/extension_api.rs | 98 ++++++++++++++++--- 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs index 230c4eb7..12fe1a92 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs @@ -37,6 +37,11 @@ pub enum ExtensionDocumentEdit { block_ix: usize, block: Block, }, + SpliceBlocks { + range: Range, + blocks: Vec, + assets: Vec, + }, ReplaceTableCell { block_ix: usize, row_ix: usize, @@ -52,6 +57,7 @@ pub enum ExtensionEditError { ReadOnly, InvalidRange, InvalidBlock(usize), + InvalidBlockRange, NotATable(usize), InvalidTableCell { block_ix: usize, row_ix: usize, cell_ix: usize }, InvalidDocument, @@ -64,6 +70,7 @@ impl fmt::Display for ExtensionEditError { Self::ReadOnly => f.write_str("document is read-only"), Self::InvalidRange => f.write_str("text range is outside the document or not on UTF-8 boundaries"), Self::InvalidBlock(block_ix) => write!(f, "block index {block_ix} is outside the document"), + Self::InvalidBlockRange => f.write_str("block splice range is outside the document"), Self::NotATable(block_ix) => write!(f, "block {block_ix} is not a table"), Self::InvalidTableCell { block_ix, row_ix, cell_ix } => { write!(f, "table cell {block_ix}:{row_ix}:{cell_ix} does not exist") @@ -130,18 +137,7 @@ fn apply_extension_edit(document: &mut Document, edit: &ExtensionDocumentEdit) - return Err(ExtensionEditError::InvalidRange); } delete_cross_paragraph_range(document, range.clone()); - for asset in &fragment.assets { - document.assets.assets.insert( - asset.id, - AssetRecord { - id: asset.id, - mime_type: asset.mime_type.clone().into(), - original_name: asset.original_name.clone().map(Into::into), - content_hash: asset.content_hash, - bytes: Arc::new(asset.bytes.clone()), - }, - ); - } + insert_extension_assets(&mut document.assets, &fragment.assets); insert_rich_fragment_at(document, range.start, fragment); }, ExtensionDocumentEdit::ReplaceBlock { block_ix, block } => { @@ -150,6 +146,16 @@ fn apply_extension_edit(document: &mut Document, edit: &ExtensionDocumentEdit) - }; *slot = block.clone(); }, + ExtensionDocumentEdit::SpliceBlocks { range, blocks, assets } => { + if range.start > range.end || range.end > document.blocks.len() { + return Err(ExtensionEditError::InvalidBlockRange); + } + let mut input_blocks = extension_input_blocks(document); + input_blocks.splice(range.clone(), blocks.iter().cloned()); + let mut replacement_assets = document.assets.clone(); + insert_extension_assets(&mut replacement_assets, assets); + *document = document_from_extension_blocks(document, input_blocks, replacement_assets); + }, ExtensionDocumentEdit::ReplaceTableCell { block_ix, row_ix, @@ -185,6 +191,74 @@ fn clamped_extension_offset(document: &Document, offset: DocumentOffset) -> Docu } } +fn insert_extension_assets(store: &mut AssetStore, assets: &[InputAsset]) { + for asset in assets { + store.assets.insert( + asset.id, + AssetRecord { + id: asset.id, + mime_type: asset.mime_type.clone().into(), + original_name: asset.original_name.clone().map(Into::into), + content_hash: asset.content_hash, + bytes: Arc::new(asset.bytes.clone()), + }, + ); + } +} + +fn extension_input_blocks(document: &Document) -> Vec { + let mut paragraph_ix = 0; + document + .blocks + .iter() + .map(|block| match block { + Block::Paragraph(_) => { + let paragraph = &document.paragraphs[paragraph_ix]; + let input = input_paragraph_from_document_range(document, paragraph_ix, 0..paragraph_text_len(paragraph)); + paragraph_ix += 1; + InputBlock::Paragraph(input) + }, + _ => input_block_from_block(block), + }) + .collect() +} + +fn document_from_extension_blocks(document: &Document, mut blocks: Vec, assets: AssetStore) -> Document { + if !blocks.iter().any(|block| matches!(block, InputBlock::Paragraph(_))) { + blocks.push(InputBlock::Paragraph(InputParagraph { + style: ParagraphStyle::Normal, + runs: Vec::new(), + })); + } + let paragraphs = blocks + .iter() + .filter_map(|block| match block { + InputBlock::Paragraph(paragraph) => Some(paragraph.clone()), + _ => None, + }) + .collect(); + let mut replacement = document_from_input(document.theme.clone(), paragraphs); + let mut paragraph_ix = 0; + replacement.blocks = Arc::new( + blocks + .iter() + .map(|block| match block { + InputBlock::Paragraph(_) => { + let block = Block::Paragraph(replacement.paragraphs[paragraph_ix].clone()); + paragraph_ix += 1; + block + }, + _ => block_from_input_block(block), + }) + .collect(), + ); + replacement.assets = assets; + replacement.ids = document.ids.clone(); + reconcile_document_ids(&mut replacement); + rebuild_document_sections(&mut replacement); + replacement +} + #[cfg(test)] mod extension_api_tests { use super::*; From d9a7e1c43a9be5425075929814d09d9a04b1f724 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:29:24 -0700 Subject: [PATCH 24/54] Expose equation source selections --- .../src/rich_text/editor/extension_snapshot.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_snapshot.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_snapshot.rs index e142d287..251c7712 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_snapshot.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_snapshot.rs @@ -22,7 +22,19 @@ impl RichTextEditor { fragment, ) }, - Some(BlockSelection::Image(block_ix) | BlockSelection::Equation(block_ix) | BlockSelection::Table(block_ix)) => { + Some(BlockSelection::Equation(block_ix)) => { + let fragment = self.selected_block_fragment().unwrap_or(text_fragment); + ( + ExtensionSelection::EquationSource { + block_ix, + anchor: self.equation_source_anchor, + head: self.equation_source_caret, + }, + self.selected_equation_source_text().unwrap_or_default(), + fragment, + ) + }, + Some(BlockSelection::Image(block_ix) | BlockSelection::Table(block_ix)) => { let fragment = self.selected_block_fragment().unwrap_or(text_fragment); ( ExtensionSelection::Object { block_ix }, From ef51a741cae5182597d16a48aa4830b573b63b62 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:29:24 -0700 Subject: [PATCH 25/54] Bridge extension editor APIs through JSON --- .../src/rich_text/editor/extension_wire.rs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs index 4111f772..c9d0e1f4 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs @@ -111,3 +111,112 @@ impl std::fmt::Display for ExtensionWireError { } impl std::error::Error for ExtensionWireError {} + +impl From for ExtensionWireError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +impl From for ExtensionWireError { + fn from(error: ExtensionEditError) -> Self { + Self::Edit(error) + } +} + +impl RichTextEditor { + pub fn extension_snapshot_json(&self) -> Result { + let snapshot = self.extension_snapshot(); + let selection = match snapshot.selection { + ExtensionSelection::Text(selection) => ExtensionWireSelection::Text { + anchor: selection.anchor.into(), + head: selection.head.into(), + }, + ExtensionSelection::Object { block_ix } => ExtensionWireSelection::Object { block_ix }, + ExtensionSelection::EquationSource { block_ix, anchor, head } => { + ExtensionWireSelection::EquationSource { block_ix, anchor, head } + }, + ExtensionSelection::TableCell { + block_ix, + row_ix, + cell_ix, + anchor, + head, + } => ExtensionWireSelection::TableCell { + block_ix, + row_ix, + cell_ix, + anchor, + head, + }, + }; + let assets = snapshot + .document + .assets + .assets + .values() + .map(|asset| InputAsset { + id: asset.id, + mime_type: asset.mime_type.to_string(), + original_name: asset.original_name.as_ref().map(ToString::to_string), + content_hash: asset.content_hash, + bytes: asset.bytes.as_ref().clone(), + }) + .collect(); + serde_json::to_string(&ExtensionWireSnapshot { + generation: snapshot.generation, + document: ExtensionWireDocument { + blocks: extension_input_blocks(&snapshot.document), + assets, + }, + selection, + selected_text: snapshot.selected_text, + selected_fragment: snapshot.selected_fragment, + }) + } + + pub fn apply_extension_edits_json( + &mut self, + request: &str, + cx: &mut Context, + ) -> Result { + let request: ExtensionWireEditRequest = serde_json::from_str(request)?; + let edits = request.edits.iter().map(extension_edit_from_wire).collect::>(); + let generation = self.apply_extension_edits(request.expected_generation, &edits, cx)?; + Ok(serde_json::to_string(&ExtensionWireEditResponse { generation })?) + } +} + +fn extension_edit_from_wire(edit: &ExtensionWireEdit) -> ExtensionDocumentEdit { + match edit { + ExtensionWireEdit::ReplaceText { start, end, fragment } => ExtensionDocumentEdit::ReplaceText { + range: (*start).into()..(*end).into(), + fragment: fragment.clone(), + }, + ExtensionWireEdit::SpliceBlocks { start, end, blocks, assets } => ExtensionDocumentEdit::SpliceBlocks { + range: *start..*end, + blocks: blocks.clone(), + assets: assets.clone(), + }, + ExtensionWireEdit::ReplaceTableCell { + block_ix, + row_ix, + cell_ix, + blocks, + } => ExtensionDocumentEdit::ReplaceTableCell { + block_ix: *block_ix, + row_ix: *row_ix, + cell_ix: *cell_ix, + blocks: blocks.iter().map(table_cell_block_from_wire).collect(), + }, + } +} + +fn table_cell_block_from_wire(block: &InputTableCellBlock) -> TableCellBlock { + match block { + InputTableCellBlock::Paragraph(paragraph) => { + TableCellBlock::Paragraph(table_cell_paragraph_from_input_paragraph(paragraph)) + }, + InputTableCellBlock::Table(table) => TableCellBlock::Table(table_from_input_table(table)), + } +} From d3324d06d22e247ba759dffcc9274713c46a66c8 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:30:22 -0700 Subject: [PATCH 26/54] Test extension history and block splices --- .../src/rich_text/editor/extension_api.rs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs index 12fe1a92..2312857d 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs @@ -317,4 +317,95 @@ mod extension_api_tests { assert_eq!(apply_extension_edit(&mut document, &edit), Err(ExtensionEditError::InvalidRange)); assert_eq!(paragraph_text(&document, 0), "café"); } + + #[test] + fn block_splice_preserves_ordered_projection() { + let mut document = text_document("before"); + let equation = InputBlock::Equation(InputEquationBlock { + source: "x^2".to_owned(), + syntax: InputEquationSyntax::Latex, + display: InputEquationDisplay::Display, + }); + let paragraph = InputBlock::Paragraph(InputParagraph { + style: ParagraphStyle::Custom(2), + runs: vec![InputRun { + text: "after".to_owned(), + styles: RunStyles::default(), + }], + }); + + apply_extension_edit( + &mut document, + &ExtensionDocumentEdit::SpliceBlocks { + range: 0..1, + blocks: vec![equation, paragraph], + assets: Vec::new(), + }, + ) + .unwrap(); + + assert!(matches!(&document.blocks[0], Block::Equation(equation) if equation.source.as_ref() == "x^2")); + assert!(matches!(&document.blocks[1], Block::Paragraph(_))); + assert_eq!(paragraph_text(&document, 0), "after"); + assert!(document_bytes(&document).is_ok()); + } + + #[gpui::test] + fn extension_batch_uses_one_generation_and_one_undo(cx: &mut gpui::TestAppContext) { + use gpui::AppContext as _; + + let editor = cx.new(|cx| RichTextEditor::new_with_path(text_document("abcd"), None, cx)); + editor.update(cx, |editor, cx| { + let replacement = |start, end, text: &str| ExtensionDocumentEdit::ReplaceText { + range: DocumentOffset { paragraph: 0, byte: start }..DocumentOffset { paragraph: 0, byte: end }, + fragment: RichClipboardFragment { + format: RICH_TEXT_CLIPBOARD_FORMAT.to_owned(), + paragraphs: vec![InputParagraph { + style: ParagraphStyle::Normal, + runs: vec![InputRun { + text: text.to_owned(), + styles: RunStyles::default(), + }], + }], + blocks: Vec::new(), + assets: Vec::new(), + }, + }; + let generation = editor + .apply_extension_edits(0, &[replacement(2, 4, "CD"), replacement(0, 2, "AB")], cx) + .unwrap(); + assert_eq!(generation, 1); + assert_eq!(paragraph_text(editor.document(), 0), "ABCD"); + assert!(matches!( + editor.apply_extension_edits(0, &[], cx), + Err(ExtensionEditError::StaleGeneration { actual: 1, .. }) + )); + + editor.undo(cx); + assert_eq!(paragraph_text(editor.document(), 0), "abcd"); + }); + } + + #[gpui::test] + fn disk_refresh_resets_extension_history_and_generation(cx: &mut gpui::TestAppContext) { + use gpui::AppContext as _; + + let editor = cx.new(|cx| RichTextEditor::new_with_path(text_document("old"), None, cx)); + editor.update(cx, |editor, cx| { + editor + .apply_extension_edits( + 0, + &[ExtensionDocumentEdit::ReplaceDocument(Box::new(text_document("edited")))], + cx, + ) + .unwrap(); + assert!(editor.has_unsaved_changes()); + + editor.replace_document_from_disk(text_document("disk"), cx); + assert_eq!(editor.edit_generation(), 0); + assert!(!editor.has_unsaved_changes()); + editor.undo(cx); + assert_eq!(paragraph_text(editor.document(), 0), "disk"); + }); + } } From 68561a9b47d720c52a102b7f960c9454d3fb97e8 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:31:10 -0700 Subject: [PATCH 27/54] Keep extension tests independent of GPUI harness --- .../src/rich_text/editor/extension_api.rs | 58 ------------------- 1 file changed, 58 deletions(-) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs index 2312857d..0b6c4e18 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs @@ -350,62 +350,4 @@ mod extension_api_tests { assert!(document_bytes(&document).is_ok()); } - #[gpui::test] - fn extension_batch_uses_one_generation_and_one_undo(cx: &mut gpui::TestAppContext) { - use gpui::AppContext as _; - - let editor = cx.new(|cx| RichTextEditor::new_with_path(text_document("abcd"), None, cx)); - editor.update(cx, |editor, cx| { - let replacement = |start, end, text: &str| ExtensionDocumentEdit::ReplaceText { - range: DocumentOffset { paragraph: 0, byte: start }..DocumentOffset { paragraph: 0, byte: end }, - fragment: RichClipboardFragment { - format: RICH_TEXT_CLIPBOARD_FORMAT.to_owned(), - paragraphs: vec![InputParagraph { - style: ParagraphStyle::Normal, - runs: vec![InputRun { - text: text.to_owned(), - styles: RunStyles::default(), - }], - }], - blocks: Vec::new(), - assets: Vec::new(), - }, - }; - let generation = editor - .apply_extension_edits(0, &[replacement(2, 4, "CD"), replacement(0, 2, "AB")], cx) - .unwrap(); - assert_eq!(generation, 1); - assert_eq!(paragraph_text(editor.document(), 0), "ABCD"); - assert!(matches!( - editor.apply_extension_edits(0, &[], cx), - Err(ExtensionEditError::StaleGeneration { actual: 1, .. }) - )); - - editor.undo(cx); - assert_eq!(paragraph_text(editor.document(), 0), "abcd"); - }); - } - - #[gpui::test] - fn disk_refresh_resets_extension_history_and_generation(cx: &mut gpui::TestAppContext) { - use gpui::AppContext as _; - - let editor = cx.new(|cx| RichTextEditor::new_with_path(text_document("old"), None, cx)); - editor.update(cx, |editor, cx| { - editor - .apply_extension_edits( - 0, - &[ExtensionDocumentEdit::ReplaceDocument(Box::new(text_document("edited")))], - cx, - ) - .unwrap(); - assert!(editor.has_unsaved_changes()); - - editor.replace_document_from_disk(text_document("disk"), cx); - assert_eq!(editor.edit_generation(), 0); - assert!(!editor.has_unsaved_changes()); - editor.undo(cx); - assert_eq!(paragraph_text(editor.document(), 0), "disk"); - }); - } } From 36ea98ea757b7039d061d2c14af42c42b10c47a5 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:31:10 -0700 Subject: [PATCH 28/54] Test extension JSON edit conversion --- .../src/rich_text/editor/extension_wire.rs | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs index c9d0e1f4..8c225b9d 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs @@ -61,13 +61,13 @@ pub struct ExtensionWireSnapshot { pub selected_fragment: RichClipboardFragment, } -#[derive(Clone, Debug, serde::Deserialize)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct ExtensionWireEditRequest { pub expected_generation: u64, pub edits: Vec, } -#[derive(Clone, Debug, serde::Deserialize)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ExtensionWireEdit { ReplaceText { @@ -220,3 +220,33 @@ fn table_cell_block_from_wire(block: &InputTableCellBlock) -> TableCellBlock { InputTableCellBlock::Table(table) => TableCellBlock::Table(table_from_input_table(table)), } } + +#[cfg(test)] +mod extension_wire_tests { + use super::*; + + #[test] + fn block_splice_request_round_trips_and_converts() { + let request = ExtensionWireEditRequest { + expected_generation: 7, + edits: vec![ExtensionWireEdit::SpliceBlocks { + start: 1, + end: 3, + blocks: vec![InputBlock::Equation(InputEquationBlock { + source: "x".to_owned(), + syntax: InputEquationSyntax::Latex, + display: InputEquationDisplay::Display, + })], + assets: Vec::new(), + }], + }; + let json = serde_json::to_string(&request).unwrap(); + let decoded: ExtensionWireEditRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.expected_generation, 7); + assert!(matches!( + extension_edit_from_wire(&decoded.edits[0]), + ExtensionDocumentEdit::SpliceBlocks { range, blocks, .. } + if range == (1..3) && matches!(&blocks[0], InputBlock::Equation(equation) if equation.source == "x") + )); + } +} From c333e223f15e375a9a157ef1cc596d985bb95b12 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:31:55 -0700 Subject: [PATCH 29/54] Add deferred directory grants to extension API --- crates/flowstate-extension/src/lib.rs | 2 +- crates/flowstate-extension/src/runtime.rs | 38 +++++++++++++++++++ .../src/runtime/host_state.rs | 19 +++++++++- crates/flowstate-extension/wit/extension.wit | 10 +++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/flowstate-extension/src/lib.rs b/crates/flowstate-extension/src/lib.rs index c628780c..1ed4da2d 100644 --- a/crates/flowstate-extension/src/lib.rs +++ b/crates/flowstate-extension/src/lib.rs @@ -5,5 +5,5 @@ mod trust; pub use discovery::{DiscoveryIssue, DiscoveryResult, InstalledExtension, discover_extensions}; pub use manifest::{ActionManifest, ExtensionManifest, ManifestError}; -pub use runtime::{CancellationHandle, ExtensionHost, HostError, Invocation, InvocationOutput, Runtime, RuntimeConfig, RuntimeError}; +pub use runtime::{CancellationHandle, DirectoryAccess, DirectoryGrant, DirectoryGrantResponse, ExtensionHost, HostError, Invocation, InvocationOutput, Runtime, RuntimeConfig, RuntimeError}; pub use trust::{ComponentDigest, TrustDecision, TrustStore}; diff --git a/crates/flowstate-extension/src/runtime.rs b/crates/flowstate-extension/src/runtime.rs index 02f6d7e3..38e997c0 100644 --- a/crates/flowstate-extension/src/runtime.rs +++ b/crates/flowstate-extension/src/runtime.rs @@ -36,6 +36,24 @@ pub struct Invocation { pub data_root: PathBuf, pub document_root: Option, pub action_id: String, + pub directory_grants: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DirectoryAccess { Read, ReadWrite } + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DirectoryGrant { + pub grant_id: String, + pub host_path: PathBuf, + pub access: DirectoryAccess, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DirectoryGrantResponse { + pub grant_id: String, + pub mount_path: String, + pub available_next_invocation: bool, } #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -55,6 +73,7 @@ pub trait ExtensionHost: Send + 'static { fn refresh_from_disk(&mut self) -> Result; fn set_action_label(&mut self, action_id: &str, label: &str) -> Result<(), HostError>; fn set_status(&mut self, message: &str); + fn request_directory_access(&mut self, mode: DirectoryAccess, suggested_path: Option<&str>) -> Result; } #[derive(Debug, Error)] @@ -156,10 +175,23 @@ fn build_wasi( if let Some(document_root) = &invocation.document_root { builder.preopened_dir(document_root, "/document", DirPerms::all(), FilePerms::all())?; } + for grant in &invocation.directory_grants { + if !valid_grant_id(&grant.grant_id) { anyhow::bail!("invalid directory grant id: {}", grant.grant_id); } + let guest_path = format!("/grants/{}", grant.grant_id); + let (dir_perms, file_perms) = match grant.access { + DirectoryAccess::Read => (DirPerms::READ, FilePerms::READ), + DirectoryAccess::ReadWrite => (DirPerms::all(), FilePerms::all()), + }; + builder.preopened_dir(&grant.host_path, guest_path, dir_perms, file_perms)?; + } if config.allow_network { builder.inherit_network(); } Ok(builder.build()) } +fn valid_grant_id(id: &str) -> bool { + !id.is_empty() && id.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + struct RunningGuard<'a> { running: &'a Mutex>, extension_id: String, @@ -213,4 +245,10 @@ mod tests { runtime.load_component(&path).unwrap(); assert_eq!(runtime.components.lock().len(), 1); } + + #[test] + fn grant_mount_ids_cannot_escape_namespace() { + for valid in ["documents", "grant-1", "grant_2"] { assert!(valid_grant_id(valid)); } + for invalid in ["", "../home", "a/b", "with space"] { assert!(!valid_grant_id(invalid)); } + } } diff --git a/crates/flowstate-extension/src/runtime/host_state.rs b/crates/flowstate-extension/src/runtime/host_state.rs index f7f54f66..9bed233a 100644 --- a/crates/flowstate-extension/src/runtime/host_state.rs +++ b/crates/flowstate-extension/src/runtime/host_state.rs @@ -3,7 +3,7 @@ use wasmtime::component::ResourceTable; use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpView}; -use super::{ExtensionHost, HostError, flowstate}; +use super::{DirectoryAccess, DirectoryGrantResponse, ExtensionHost, HostError, flowstate}; pub(super) struct State { wasi: WasiCtx, @@ -35,6 +35,23 @@ impl flowstate::extension::host::Host for State { fn refresh_from_disk(&mut self) -> Result { self.host.refresh_from_disk().map_err(Into::into) } fn set_action_label(&mut self, action_id: String, label: String) -> Result<(), flowstate::extension::host::HostError> { self.host.set_action_label(&action_id, &label).map_err(Into::into) } fn set_status(&mut self, message: String) { self.host.set_status(&message); } + fn request_directory_access( + &mut self, + mode: flowstate::extension::host::DirectoryAccess, + suggested_path: Option, + ) -> Result { + let mode = match mode { + flowstate::extension::host::DirectoryAccess::Read => DirectoryAccess::Read, + flowstate::extension::host::DirectoryAccess::ReadWrite => DirectoryAccess::ReadWrite, + }; + self.host.request_directory_access(mode, suggested_path.as_deref()).map(Into::into).map_err(Into::into) + } +} + +impl From for flowstate::extension::host::DirectoryGrant { + fn from(grant: DirectoryGrantResponse) -> Self { + Self { grant_id: grant.grant_id, mount_path: grant.mount_path, available_next_invocation: grant.available_next_invocation } + } } impl From for flowstate::extension::host::HostError { diff --git a/crates/flowstate-extension/wit/extension.wit b/crates/flowstate-extension/wit/extension.wit index ecbd158a..3f5c9194 100644 --- a/crates/flowstate-extension/wit/extension.wit +++ b/crates/flowstate-extension/wit/extension.wit @@ -5,6 +5,12 @@ interface host { code: string, message: string, } + enum directory-access { read, read-write } + record directory-grant { + grant-id: string, + mount-path: string, + available-next-invocation: bool, + } snapshot: func() -> result; selection: func() -> result; @@ -12,6 +18,10 @@ interface host { refresh-from-disk: func() -> result; set-action-label: func(action-id: string, label: string) -> result<_, host-error>; set-status: func(message: string); + request-directory-access: func( + mode: directory-access, + suggested-path: option, + ) -> result; } world extension { From 99940c0696812b359b26a868b47d0e5b2bf9f9e2 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:31:55 -0700 Subject: [PATCH 30/54] Exercise component invocation and host callback --- .../tests/fixtures/success.wat | 46 +++++++++++++++++++ .../flowstate-extension/tests/runtime_e2e.rs | 42 +++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 crates/flowstate-extension/tests/fixtures/success.wat create mode 100644 crates/flowstate-extension/tests/runtime_e2e.rs diff --git a/crates/flowstate-extension/tests/fixtures/success.wat b/crates/flowstate-extension/tests/fixtures/success.wat new file mode 100644 index 00000000..e1e534b5 --- /dev/null +++ b/crates/flowstate-extension/tests/fixtures/success.wat @@ -0,0 +1,46 @@ +(component + (type $host-type (instance + (type $status-type (func (param "message" string))) + (export "set-status" (func (type $status-type))) + )) + (import "flowstate:extension/host@1.0.0" (instance $host (type $host-type))) + (alias export $host "set-status" (func $status)) + + (core module $memory-module + (memory (export "memory") 1) + (data (i32.const 32) "called") + ) + (core instance $memory-instance (instantiate $memory-module)) + (alias core export $memory-instance "memory" (core memory $memory)) + (core func $status-core + (canon lower (func $status) (memory $memory) string-encoding=utf8) + ) + (core instance $imports + (export "memory" (memory $memory)) + (export "set-status" (func $status-core)) + ) + + (core module $guest + (import "imports" "memory" (memory 1)) + (import "imports" "set-status" (func $status (param i32 i32))) + (func (export "cabi_realloc") (param i32 i32 i32 i32) (result i32) i32.const 1024) + (func (export "run") (param i32 i32) (result i32) + i32.const 32 + i32.const 6 + call $status + i32.const 2048 + i32.const 0 + i32.store + i32.const 2048 + ) + ) + (core instance $guest-instance (instantiate $guest (with "imports" (instance $imports)))) + (alias core export $guest-instance "cabi_realloc" (core func $realloc)) + (alias core export $guest-instance "run" (core func $run-core)) + + (type $run-type (func (param "action-id" string) (result (result (error string))))) + (func $run (type $run-type) + (canon lift (core func $run-core) (memory $memory) (realloc $realloc) string-encoding=utf8) + ) + (export "run" (func $run)) +) diff --git a/crates/flowstate-extension/tests/runtime_e2e.rs b/crates/flowstate-extension/tests/runtime_e2e.rs new file mode 100644 index 00000000..48fba0ba --- /dev/null +++ b/crates/flowstate-extension/tests/runtime_e2e.rs @@ -0,0 +1,42 @@ +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + use flowstate_extension::{DirectoryAccess, DirectoryGrantResponse, ExtensionHost, HostError, Invocation, Runtime, RuntimeConfig}; + + struct Host { status: Arc>> } + + impl ExtensionHost for Host { + fn snapshot(&mut self) -> Result { Ok("{}".to_owned()) } + fn selection(&mut self) -> Result { Ok("{}".to_owned()) } + fn apply_edits(&mut self, _: &str) -> Result { Ok("{}".to_owned()) } + fn refresh_from_disk(&mut self) -> Result { Ok("{}".to_owned()) } + fn set_action_label(&mut self, _: &str, _: &str) -> Result<(), HostError> { Ok(()) } + fn set_status(&mut self, message: &str) { self.status.lock().unwrap().push(message.to_owned()); } + fn request_directory_access(&mut self, _: DirectoryAccess, _: Option<&str>) -> Result { + Err(HostError { code: "denied".to_owned(), message: "not used".to_owned() }) + } + } + + #[test] + fn invokes_component_export_end_to_end() { + let directory = tempfile::tempdir().unwrap(); + let component = directory.path().join("extension.wat"); + std::fs::write(&component, include_str!("fixtures/success.wat")).unwrap(); + let invocation = Invocation { + component, + extension_root: directory.path().to_path_buf(), + data_root: directory.path().join("data"), + document_root: None, + action_id: "run".to_owned(), + directory_grants: Vec::new(), + }; + let runtime = Runtime::new(RuntimeConfig { allow_network: false, ..RuntimeConfig::default() }).unwrap(); + let cancellation = runtime.cancellation_handle(); + let status = Arc::new(Mutex::new(Vec::new())); + let output = runtime.invoke("com.example.test", &invocation, Host { status: Arc::clone(&status) }, &cancellation).unwrap(); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + assert!(invocation.data_root.is_dir()); + assert_eq!(*status.lock().unwrap(), ["called"]); + } +} From e590de247ddf58292b8029e894e1ff24bb5903ba Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:32:39 -0700 Subject: [PATCH 31/54] Verify cancellation against running component --- crates/flowstate-extension/src/runtime.rs | 6 ++- .../flowstate-extension/tests/runtime_e2e.rs | 40 ++++++++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/flowstate-extension/src/runtime.rs b/crates/flowstate-extension/src/runtime.rs index 38e997c0..285e6578 100644 --- a/crates/flowstate-extension/src/runtime.rs +++ b/crates/flowstate-extension/src/runtime.rs @@ -143,7 +143,11 @@ impl Runtime { } flowstate::extension::host::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |state| state)?; let instance = Extension::instantiate(&mut store, &component, &linker)?; - let result = instance.call_run(&mut store, &invocation.action_id)?; + let result = match instance.call_run(&mut store, &invocation.action_id) { + Ok(result) => result, + Err(_) if cancellation.cancelled.load(Ordering::Acquire) => return Err(RuntimeError::Cancelled), + Err(error) => return Err(RuntimeError::Wasmtime(error)), + }; if cancellation.cancelled.load(Ordering::Acquire) { return Err(RuntimeError::Cancelled); } result.map_err(|message| RuntimeError::Wasmtime(anyhow::anyhow!(message)))?; Ok(InvocationOutput { stdout: stdout.contents().to_vec(), stderr: stderr.contents().to_vec() }) diff --git a/crates/flowstate-extension/tests/runtime_e2e.rs b/crates/flowstate-extension/tests/runtime_e2e.rs index 48fba0ba..60368c30 100644 --- a/crates/flowstate-extension/tests/runtime_e2e.rs +++ b/crates/flowstate-extension/tests/runtime_e2e.rs @@ -1,9 +1,9 @@ #[cfg(test)] mod tests { - use std::sync::{Arc, Mutex}; - use flowstate_extension::{DirectoryAccess, DirectoryGrantResponse, ExtensionHost, HostError, Invocation, Runtime, RuntimeConfig}; + use std::sync::{Arc, Condvar, Mutex}; + use flowstate_extension::{DirectoryAccess, DirectoryGrantResponse, ExtensionHost, HostError, Invocation, Runtime, RuntimeConfig, RuntimeError}; - struct Host { status: Arc>> } + struct Host { status: Arc>>, signal: Option, Condvar)>> } impl ExtensionHost for Host { fn snapshot(&mut self) -> Result { Ok("{}".to_owned()) } @@ -11,7 +11,13 @@ mod tests { fn apply_edits(&mut self, _: &str) -> Result { Ok("{}".to_owned()) } fn refresh_from_disk(&mut self) -> Result { Ok("{}".to_owned()) } fn set_action_label(&mut self, _: &str, _: &str) -> Result<(), HostError> { Ok(()) } - fn set_status(&mut self, message: &str) { self.status.lock().unwrap().push(message.to_owned()); } + fn set_status(&mut self, message: &str) { + self.status.lock().unwrap().push(message.to_owned()); + if let Some(signal) = &self.signal { + *signal.0.lock().unwrap() = true; + signal.1.notify_one(); + } + } fn request_directory_access(&mut self, _: DirectoryAccess, _: Option<&str>) -> Result { Err(HostError { code: "denied".to_owned(), message: "not used".to_owned() }) } @@ -33,10 +39,34 @@ mod tests { let runtime = Runtime::new(RuntimeConfig { allow_network: false, ..RuntimeConfig::default() }).unwrap(); let cancellation = runtime.cancellation_handle(); let status = Arc::new(Mutex::new(Vec::new())); - let output = runtime.invoke("com.example.test", &invocation, Host { status: Arc::clone(&status) }, &cancellation).unwrap(); + let output = runtime.invoke("com.example.test", &invocation, Host { status: Arc::clone(&status), signal: None }, &cancellation).unwrap(); assert!(output.stdout.is_empty()); assert!(output.stderr.is_empty()); assert!(invocation.data_root.is_dir()); assert_eq!(*status.lock().unwrap(), ["called"]); } + + #[test] + fn cancels_a_running_component() { + let directory = tempfile::tempdir().unwrap(); + let component = directory.path().join("infinite.wat"); + let source = include_str!("fixtures/success.wat").replace( + "i32.const 2048\n i32.const 0\n i32.store\n i32.const 2048", + "(loop $forever br $forever)\n unreachable", + ); + std::fs::write(&component, source).unwrap(); + let invocation = Invocation { component, extension_root: directory.path().into(), data_root: directory.path().join("data"), document_root: None, action_id: "run".into(), directory_grants: Vec::new() }; + let runtime = Arc::new(Runtime::new(RuntimeConfig { allow_network: false, ..RuntimeConfig::default() }).unwrap()); + let cancellation = runtime.cancellation_handle(); + let signal = Arc::new((Mutex::new(false), Condvar::new())); + let thread_runtime = Arc::clone(&runtime); + let thread_cancellation = cancellation.clone(); + let thread_signal = Arc::clone(&signal); + let worker = std::thread::spawn(move || thread_runtime.invoke("com.example.infinite", &invocation, Host { status: Arc::new(Mutex::new(Vec::new())), signal: Some(thread_signal) }, &thread_cancellation)); + let mut started = signal.0.lock().unwrap(); + while !*started { started = signal.1.wait(started).unwrap(); } + drop(started); + cancellation.cancel(); + assert!(matches!(worker.join().unwrap(), Err(RuntimeError::Cancelled))); + } } From b8a4e18a4c9d1ba00799f3d357f5afb77d0b3e5f Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:33:16 -0700 Subject: [PATCH 32/54] Tighten component cache lock scopes --- crates/flowstate-extension/src/runtime.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/flowstate-extension/src/runtime.rs b/crates/flowstate-extension/src/runtime.rs index 285e6578..03863f5e 100644 --- a/crates/flowstate-extension/src/runtime.rs +++ b/crates/flowstate-extension/src/runtime.rs @@ -156,11 +156,14 @@ impl Runtime { fn load_component(&self, path: &std::path::Path) -> Result { let digest = ComponentDigest::from_file(path).map_err(anyhow::Error::from)?; let key = (path.to_path_buf(), digest); - if let Some(component) = self.components.lock().get(&key).cloned() { return Ok(component); } + let cached = { self.components.lock().get(&key).cloned() }; + if let Some(component) = cached { return Ok(component); } let component = Component::from_file(&self.engine, path)?; - let mut components = self.components.lock(); - components.retain(|(cached_path, _), _| cached_path != path); - components.insert(key, component.clone()); + { + let mut components = self.components.lock(); + components.retain(|(cached_path, _), _| cached_path != path); + components.insert(key, component.clone()) + }; Ok(component) } } From 73cd50b38fef0d9c9e4f876361dc3cc1c3ce57dc Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:35:28 -0700 Subject: [PATCH 33/54] Connect extension discovery runtime and trust --- Cargo.lock | 1 + crates/flowstate/Cargo.toml | 1 + .../src/workspace/extensions_panel/mod.rs | 10 +- .../src/workspace/extensions_panel/model.rs | 4 - .../src/workspace/extensions_panel/service.rs | 277 ++++++++++++++++++ .../src/workspace/workspace/documents.rs | 8 +- .../flowstate/src/workspace/workspace/mod.rs | 5 +- 7 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 crates/flowstate/src/workspace/extensions_panel/service.rs diff --git a/Cargo.lock b/Cargo.lock index 30582414..6629f968 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2809,6 +2809,7 @@ dependencies = [ "fff-search", "flowstate-document", "flowstate-docx", + "flowstate-extension", "flowstate-flow", "flowstate-tub", "gpui", diff --git a/crates/flowstate/Cargo.toml b/crates/flowstate/Cargo.toml index 7368610c..a5e57e94 100644 --- a/crates/flowstate/Cargo.toml +++ b/crates/flowstate/Cargo.toml @@ -15,6 +15,7 @@ dirs = { workspace = true } fff-search = { workspace = true } flowstate-document = { workspace = true } flowstate-docx = { workspace = true } +flowstate-extension = { version = "0.1.0", path = "../flowstate-extension" } flowstate-flow = { workspace = true } flowstate-tub = { workspace = true } gpui = { workspace = true } diff --git a/crates/flowstate/src/workspace/extensions_panel/mod.rs b/crates/flowstate/src/workspace/extensions_panel/mod.rs index 88fbfee2..084a2450 100644 --- a/crates/flowstate/src/workspace/extensions_panel/mod.rs +++ b/crates/flowstate/src/workspace/extensions_panel/mod.rs @@ -1,12 +1,16 @@ +mod bridge; mod model; +mod service; use std::sync::Arc; use gpui::SharedString; pub use model::{ - ExtensionPanelAdapter, ExtensionPanelController, ExtensionPanelEvent, ExtensionRunState, ExtensionView, + ExtensionActionView, ExtensionPanelAdapter, ExtensionPanelController, ExtensionPanelEvent, ExtensionRunState, ExtensionView, }; +pub use service::ExtensionService; +pub use bridge::{EditorHostBridge, EditorHostRequest}; struct DisconnectedExtensionAdapter; @@ -23,10 +27,6 @@ impl ExtensionPanelAdapter for DisconnectedExtensionAdapter { Err("Extension runtime is not connected".into()) } - fn invoke(&self, _: &str, _: &str) -> Result<(), SharedString> { - Err("Extension runtime is not connected".into()) - } - fn cancel(&self, _: &str) -> Result<(), SharedString> { Err("Extension runtime is not connected".into()) } diff --git a/crates/flowstate/src/workspace/extensions_panel/model.rs b/crates/flowstate/src/workspace/extensions_panel/model.rs index d8c05e32..9cb8946c 100644 --- a/crates/flowstate/src/workspace/extensions_panel/model.rs +++ b/crates/flowstate/src/workspace/extensions_panel/model.rs @@ -41,7 +41,6 @@ pub trait ExtensionPanelAdapter: Send + Sync { fn installed(&self) -> Result, SharedString>; fn is_trusted(&self, extension_id: &str, component_hash: &str) -> bool; fn trust(&self, extension_id: &str, component_hash: &str) -> Result<(), SharedString>; - fn invoke(&self, extension_id: &str, action_id: &str) -> Result<(), SharedString>; fn cancel(&self, extension_id: &str) -> Result<(), SharedString>; } @@ -125,9 +124,6 @@ impl ExtensionPanelController { ExtensionRunState::Running { action_id: SharedString::from(action_id.to_owned()) }, ); self.outputs.remove(extension_id); - if let Err(error) = self.adapter.invoke(extension_id, action_id) { - self.states.insert(SharedString::from(extension_id.to_owned()), ExtensionRunState::Failed(error)); - } } pub fn cancel(&mut self, extension_id: &str) { diff --git a/crates/flowstate/src/workspace/extensions_panel/service.rs b/crates/flowstate/src/workspace/extensions_panel/service.rs new file mode 100644 index 00000000..47127ed2 --- /dev/null +++ b/crates/flowstate/src/workspace/extensions_panel/service.rs @@ -0,0 +1,277 @@ +use std::{ + collections::HashMap, + hash::{DefaultHasher, Hash, Hasher}, + fs, + path::PathBuf, + sync::{Arc, Mutex, mpsc}, + thread, +}; + +use flowstate_extension::{ + CancellationHandle, ComponentDigest, ExtensionHost, HostError, InstalledExtension, Invocation, Runtime, RuntimeConfig, RuntimeError, TrustDecision, + TrustStore, discover_extensions, +}; +use gpui::SharedString; +use serde::{Deserialize, Serialize}; + +use super::{ExtensionActionView, ExtensionPanelAdapter, ExtensionPanelEvent, ExtensionView}; + +pub struct ExtensionService { + extensions_root: PathBuf, + data_root: PathBuf, + trust_path: PathBuf, + grants_path: PathBuf, + runtime: Arc, + installed: Mutex>, + trust: Mutex, + grants: Mutex>, + running: Arc>>, + events_tx: mpsc::SyncSender, + events_rx: Mutex>, +} + +impl ExtensionService { + pub fn new(flowstate_data: PathBuf) -> Result, SharedString> { + let extensions_root = flowstate_data.join("extensions"); + let data_root = flowstate_data.join("extension-data"); + let trust_path = flowstate_data.join("extension-trust.json"); + let grants_path = flowstate_data.join("extension-directory-grants.json"); + fs::create_dir_all(&extensions_root).map_err(shared_error)?; + fs::create_dir_all(&data_root).map_err(shared_error)?; + let trust = fs::read(&trust_path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default(); + let runtime = Runtime::new(RuntimeConfig::default()).map_err(shared_error)?; + let grants = fs::read(&grants_path).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()).unwrap_or_default(); + let (events_tx, events_rx) = mpsc::sync_channel(256); + Ok(Arc::new(Self { + extensions_root, + data_root, + trust_path, + grants_path, + runtime: Arc::new(runtime), + installed: Mutex::new(HashMap::new()), + trust: Mutex::new(trust), + grants: Mutex::new(grants), + running: Arc::new(Mutex::new(HashMap::new())), + events_tx, + events_rx: Mutex::new(events_rx), + })) + } + + pub fn invoke_with_host( + self: &Arc, + extension_id: &str, + action_id: &str, + document_root: Option, + host: Box, + ) -> Result<(), SharedString> { + let extension = self + .installed + .lock() + .map_err(shared_error)? + .get(extension_id) + .cloned() + .ok_or_else(|| SharedString::from("Extension is no longer installed"))?; + let directory_grants = self.directory_grants(extension_id, &extension.component_path)?; + let invocation = Invocation { + component: extension.component_path, + extension_root: extension.root, + data_root: self.data_root.join(extension_id), + document_root, + action_id: action_id.to_owned(), + directory_grants, + }; + let cancellation = self.runtime.cancellation_handle(); + self.running.lock().map_err(shared_error)?.insert(extension_id.to_owned(), cancellation.clone()); + let service = Arc::clone(self); + let extension_id = extension_id.to_owned(); + let action_id = action_id.to_owned(); + thread::Builder::new() + .name(format!("flowstate-extension-{extension_id}")) + .spawn(move || { + let _ = service.events_tx.send(ExtensionPanelEvent::Started { + extension_id: extension_id.clone().into(), + action_id: action_id.into(), + }); + let result = service.runtime.invoke(&extension_id, &invocation, BoxedHost(host), &cancellation); + service.running.lock().ok().map(|mut running| running.remove(&extension_id)); + let event = match result { + Ok(output) => { + let mut bytes = output.stdout; + bytes.extend(output.stderr); + if !bytes.is_empty() { + let _ = service.events_tx.send(ExtensionPanelEvent::Output { + extension_id: extension_id.clone().into(), + output: String::from_utf8_lossy(&bytes).into_owned().into(), + }); + } + ExtensionPanelEvent::Finished { extension_id: extension_id.into() } + }, + Err(RuntimeError::Cancelled) => ExtensionPanelEvent::Cancelled { extension_id: extension_id.into() }, + Err(error) => ExtensionPanelEvent::Failed { extension_id: extension_id.into(), message: error.to_string().into() }, + }; + let _ = service.events_tx.send(event); + }) + .map_err(shared_error)?; + Ok(()) + } + + pub fn drain_events(&self) -> Vec { + let Ok(receiver) = self.events_rx.lock() else { return Vec::new() }; + std::iter::from_fn(|| receiver.try_recv().ok()).collect() + } + + pub fn grant_directory( + &self, + extension_id: &str, + access: flowstate_extension::DirectoryAccess, + host_path: PathBuf, + ) -> Result { + let extension = self.installed.lock().map_err(host_service)?.get(extension_id).cloned().ok_or_else(|| host_service("extension missing"))?; + let digest = ComponentDigest::from_file(&extension.component_path).map_err(host_service)?; + let mut path_hash = DefaultHasher::new(); + host_path.hash(&mut path_hash); + let grant_id = format!("grant-{:016x}", path_hash.finish()); + let persisted = PersistedGrant { + extension_id: extension_id.to_owned(), + component_hash: digest.as_str().to_owned(), + grant_id: grant_id.clone(), + host_path, + read_write: access == flowstate_extension::DirectoryAccess::ReadWrite, + }; + let mut grants = self.grants.lock().map_err(host_service)?; + grants.retain(|grant| !(grant.extension_id == extension_id && grant.grant_id == grant_id)); + grants.push(persisted); + let bytes = serde_json::to_vec_pretty(&*grants).map_err(host_service)?; + drop(grants); + fs::write(&self.grants_path, bytes).map_err(host_service)?; + Ok(flowstate_extension::DirectoryGrantResponse { + grant_id: grant_id.clone(), + mount_path: format!("/grants/{grant_id}"), + available_next_invocation: true, + }) + } + + fn directory_grants(&self, extension_id: &str, component: &std::path::Path) -> Result, SharedString> { + let digest = ComponentDigest::from_file(component).map_err(shared_error)?; + let grants = self.grants.lock().map_err(shared_error)?; + let result = grants.iter().filter(|grant| grant.extension_id == extension_id && grant.component_hash == digest.as_str()).map(|grant| flowstate_extension::DirectoryGrant { + grant_id: grant.grant_id.clone(), + host_path: grant.host_path.clone(), + access: if grant.read_write { flowstate_extension::DirectoryAccess::ReadWrite } else { flowstate_extension::DirectoryAccess::Read }, + }).collect(); + drop(grants); + Ok(result) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct PersistedGrant { + extension_id: String, + component_hash: String, + grant_id: String, + host_path: PathBuf, + read_write: bool, +} + +fn host_service(error: impl std::fmt::Display) -> HostError { + HostError { code: "directory-grant".to_owned(), message: error.to_string() } +} + +struct BoxedHost(Box); + +impl ExtensionHost for BoxedHost { + fn snapshot(&mut self) -> Result { self.0.snapshot() } + fn selection(&mut self) -> Result { self.0.selection() } + fn apply_edits(&mut self, edits_json: &str) -> Result { self.0.apply_edits(edits_json) } + fn refresh_from_disk(&mut self) -> Result { self.0.refresh_from_disk() } + fn set_action_label(&mut self, action_id: &str, label: &str) -> Result<(), flowstate_extension::HostError> { + self.0.set_action_label(action_id, label) + } + fn set_status(&mut self, message: &str) { self.0.set_status(message); } + fn request_directory_access( + &mut self, + mode: flowstate_extension::DirectoryAccess, + suggested_path: Option<&str>, + ) -> Result { + self.0.request_directory_access(mode, suggested_path) + } +} + +fn shared_error(error: impl std::fmt::Display) -> SharedString { + error.to_string().into() +} + +impl ExtensionPanelAdapter for ExtensionService { + fn installed(&self) -> Result, SharedString> { + let discovery = discover_extensions(&self.extensions_root); + let mut discovered = HashMap::new(); + let mut views = Vec::with_capacity(discovery.extensions.len()); + for extension in discovery.extensions { + let digest = ComponentDigest::from_file(&extension.component_path).map_err(shared_error)?; + views.push(ExtensionView { + id: extension.manifest.id.clone().into(), + name: extension.manifest.name.clone().into(), + version: extension.manifest.version.clone().into(), + component_hash: digest.as_str().to_owned().into(), + actions: extension + .manifest + .actions + .iter() + .map(|action| ExtensionActionView { + id: action.id.clone().into(), + label: action.label.clone().into(), + requires_document: action.requires_document, + }) + .collect(), + }); + discovered.insert(extension.manifest.id.clone(), extension); + } + if views.is_empty() && !discovery.issues.is_empty() { + return Err(discovery + .issues + .into_iter() + .map(|issue| format!("{}: {}", issue.path.display(), issue.message)) + .collect::>() + .join("\n") + .into()); + } + *self.installed.lock().map_err(shared_error)? = discovered; + Ok(views) + } + + fn is_trusted(&self, extension_id: &str, component_hash: &str) -> bool { + let Ok(installed) = self.installed.lock() else { return false }; + let Some(extension) = installed.get(extension_id) else { return false }; + let Ok(digest) = ComponentDigest::from_file(&extension.component_path) else { return false }; + if digest.as_str() != component_hash { return false } + self + .trust + .lock() + .is_ok_and(|trust| trust.decision(extension_id, &digest) == TrustDecision::Trusted) + } + + fn trust(&self, extension_id: &str, component_hash: &str) -> Result<(), SharedString> { + let installed = self.installed.lock().map_err(shared_error)?; + let component = installed.get(extension_id).ok_or_else(|| SharedString::from("Extension is no longer installed"))?.component_path.clone(); + drop(installed); + let digest = ComponentDigest::from_file(&component).map_err(shared_error)?; + if digest.as_str() != component_hash { return Err("Extension changed before approval".into()) } + let mut trust = self.trust.lock().map_err(shared_error)?; + trust.approve(extension_id, digest); + let bytes = serde_json::to_vec_pretty(&*trust).map_err(shared_error)?; + drop(trust); + if let Some(parent) = self.trust_path.parent() { fs::create_dir_all(parent).map_err(shared_error)?; } + fs::write(&self.trust_path, bytes).map_err(shared_error) + } + + fn cancel(&self, extension_id: &str) -> Result<(), SharedString> { + let running = self.running.lock().map_err(shared_error)?; + let handle = running.get(extension_id).cloned().ok_or_else(|| SharedString::from("Extension is not running"))?; + drop(running); + handle.cancel(); + Ok(()) + } +} diff --git a/crates/flowstate/src/workspace/workspace/documents.rs b/crates/flowstate/src/workspace/workspace/documents.rs index f00fa48d..938c8b41 100644 --- a/crates/flowstate/src/workspace/workspace/documents.rs +++ b/crates/flowstate/src/workspace/workspace/documents.rs @@ -1,6 +1,11 @@ #[hotpath::measure_all] impl Workspace { pub fn new(initial_path: Option, window: &mut Window, cx: &mut Context) -> Self { + let extension_service = crate::workspace::extensions_panel::ExtensionService::new(crate::app_settings::flowstate_data_dir()).ok(); + let extension_controller = extension_service + .as_ref() + .map(|service| ExtensionPanelController::new(service.clone())) + .unwrap_or_else(crate::workspace::extensions_panel::disconnected_controller); let zoom_slider = cx.new(|_| { SliderState::new() .min(25.0) @@ -57,7 +62,8 @@ impl Workspace { outline_collapsed: false, toolkit_collapsed: false, active_toolkit_tool: None, - extensions: crate::workspace::extensions_panel::disconnected_controller(), + extensions: extension_controller, + extension_service, expanded_extensions: HashSet::new(), recent_documents: load_recent_documents(), recent_document_previews: HashMap::new(), diff --git a/crates/flowstate/src/workspace/workspace/mod.rs b/crates/flowstate/src/workspace/workspace/mod.rs index 5b345196..bf37f5ea 100644 --- a/crates/flowstate/src/workspace/workspace/mod.rs +++ b/crates/flowstate/src/workspace/workspace/mod.rs @@ -53,7 +53,7 @@ use crate::workspace::file_management::{ UNTITLED_DOCUMENT_NAME, UNTITLED_FLOW_NAME, default_save_directory, new_blank_document, normalize_db8_path, normalize_fl0_path, }; use crate::workspace::file_search_overlay::FileSearchOverlay; -use crate::workspace::extensions_panel::ExtensionPanelController; +use crate::workspace::extensions_panel::{ExtensionPanelController, ExtensionService}; use crate::workspace::icons::{AppIcon, icon_button}; use flowstate_tub::{SearchHit, SearchUnitKind, TubFile, TubIndex, TubTreeNode}; @@ -64,6 +64,8 @@ const SIDE_PANEL_COLLAPSED_WIDTH: Pixels = px(30.0); mod toolkit_panel; #[path = "../extensions_tool.rs"] mod extensions_tool; +#[path = "../extension_host_loop.rs"] +mod extension_host_loop; pub struct Workspace { document_panels: Vec>, @@ -76,6 +78,7 @@ pub struct Workspace { toolkit_collapsed: bool, active_toolkit_tool: Option, extensions: ExtensionPanelController, + extension_service: Option>, expanded_extensions: HashSet, recent_documents: Vec, recent_document_previews: HashMap, From ee41f08d56e83be460a282fa36070176892254fc Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:35:38 -0700 Subject: [PATCH 34/54] Add bounded extension editor host bridge --- .../src/workspace/extensions_panel/bridge.rs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 crates/flowstate/src/workspace/extensions_panel/bridge.rs diff --git a/crates/flowstate/src/workspace/extensions_panel/bridge.rs b/crates/flowstate/src/workspace/extensions_panel/bridge.rs new file mode 100644 index 00000000..9d117965 --- /dev/null +++ b/crates/flowstate/src/workspace/extensions_panel/bridge.rs @@ -0,0 +1,97 @@ +use std::sync::mpsc::{self, Receiver, SyncSender}; + +use flowstate_extension::{DirectoryAccess, DirectoryGrantResponse, ExtensionHost, HostError}; + +pub enum EditorHostRequest { + Snapshot(SyncSender>), + Selection(SyncSender>), + ApplyEdits(String, SyncSender>), + Refresh(SyncSender>), + ActionLabel(String, String), + Status(String), + DirectoryAccess(DirectoryAccess, Option, SyncSender>), +} + +pub struct EditorHostBridge { + requests: SyncSender, +} + +impl EditorHostBridge { + pub fn bounded(capacity: usize) -> (Self, Receiver) { + let (requests, receiver) = mpsc::sync_channel(capacity); + (Self { requests }, receiver) + } + + fn request( + &self, + build: impl FnOnce(SyncSender>) -> EditorHostRequest, + ) -> Result { + let (reply, response) = mpsc::sync_channel(1); + self.requests.send(build(reply)).map_err(|_| unavailable())?; + response.recv().map_err(|_| unavailable())? + } +} + +impl ExtensionHost for EditorHostBridge { + fn snapshot(&mut self) -> Result { + self.request(EditorHostRequest::Snapshot) + } + + fn selection(&mut self) -> Result { + self.request(EditorHostRequest::Selection) + } + + fn apply_edits(&mut self, edits_json: &str) -> Result { + self.request(|reply| EditorHostRequest::ApplyEdits(edits_json.to_owned(), reply)) + } + + fn refresh_from_disk(&mut self) -> Result { + self.request(EditorHostRequest::Refresh) + } + + fn set_action_label(&mut self, action_id: &str, label: &str) -> Result<(), HostError> { + self + .requests + .send(EditorHostRequest::ActionLabel(action_id.to_owned(), label.to_owned())) + .map_err(|_| unavailable()) + } + + fn set_status(&mut self, message: &str) { + let _ = self.requests.send(EditorHostRequest::Status(message.to_owned())); + } + + fn request_directory_access(&mut self, mode: DirectoryAccess, suggested_path: Option<&str>) -> Result { + let (reply, response) = mpsc::sync_channel(1); + self + .requests + .send(EditorHostRequest::DirectoryAccess(mode, suggested_path.map(ToOwned::to_owned), reply)) + .map_err(|_| unavailable())?; + response.recv().map_err(|_| unavailable())? + } +} + +fn unavailable() -> HostError { + HostError { code: "host-unavailable".to_owned(), message: "Flowstate closed the extension host".to_owned() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_request_round_trips_over_bounded_bridge() { + let (mut host, requests) = EditorHostBridge::bounded(1); + let worker = std::thread::spawn(move || host.snapshot().unwrap()); + let EditorHostRequest::Snapshot(reply) = requests.recv().unwrap() else { panic!("expected snapshot request") }; + reply.send(Ok("{\"generation\":7}".to_owned())).unwrap(); + assert_eq!(worker.join().unwrap(), "{\"generation\":7}"); + } + + #[test] + fn closed_bridge_returns_typed_host_error() { + let (mut host, requests) = EditorHostBridge::bounded(1); + drop(requests); + let error = host.selection().unwrap_err(); + assert_eq!(error.code, "host-unavailable"); + } +} From 566d33ce56ad09bd15db3feb5f90a927d442435e Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:35:38 -0700 Subject: [PATCH 35/54] Bridge extension actions to active editor --- .../src/workspace/extension_host_loop.rs | 175 ++++++++++++++++++ .../src/workspace/extensions_tool.rs | 31 +++- 2 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 crates/flowstate/src/workspace/extension_host_loop.rs diff --git a/crates/flowstate/src/workspace/extension_host_loop.rs b/crates/flowstate/src/workspace/extension_host_loop.rs new file mode 100644 index 00000000..caaa1c63 --- /dev/null +++ b/crates/flowstate/src/workspace/extension_host_loop.rs @@ -0,0 +1,175 @@ +use std::{sync::mpsc::Receiver, time::Duration}; + +use flowstate_extension::HostError; +use gpui::{AnyWindowHandle, Context, Entity, PathPromptOptions, PromptButton, PromptLevel, SharedString, Timer}; + +use crate::rich_text_element::{RichTextEditor, load_or_create_document}; +use super::super::extensions_panel::{EditorHostRequest, ExtensionPanelEvent}; +use super::Workspace; + +impl Workspace { + pub(super) fn spawn_extension_host_loop( + &self, + extension_id: SharedString, + editor: Option>, + requests: Receiver, + window: AnyWindowHandle, + cx: &mut Context, + ) { + cx.spawn(async move |workspace, cx| { + let mut host_disconnected = false; + loop { + while let Ok(request) = requests.try_recv() { + match request { + EditorHostRequest::Snapshot(reply) => { + let result = editor.as_ref().ok_or_else(no_document).and_then(|editor| editor.read_with(cx, |editor, _| editor.extension_snapshot_json()).map_err(host_access).and_then(|value| value.map_err(host_json))); + let _ = reply.send(result); + }, + EditorHostRequest::Selection(reply) => { + let result = editor.as_ref().ok_or_else(no_document).and_then(|editor| editor + .read_with(cx, |editor, _| editor.extension_snapshot_json()) + .map_err(host_access) + .and_then(|value| value.map_err(host_json)) + .and_then(selection_json)); + let _ = reply.send(result); + }, + EditorHostRequest::ApplyEdits(edits, reply) => { + let result = editor.as_ref().ok_or_else(no_document).and_then(|editor| editor + .update(cx, |editor, cx| editor.apply_extension_edits_json(&edits, cx)) + .map_err(host_access) + .and_then(|value| value.map_err(|error| host_error("invalid-edits", error)))); + let _ = reply.send(result); + }, + EditorHostRequest::Refresh(reply) => { + let result = match editor.as_ref() { + Some(editor) => refresh_editor_from_disk(editor, window, cx).await, + None => Err(no_document()), + }; + let _ = reply.send(result); + }, + EditorHostRequest::ActionLabel(action_id, label) => { + let _ = workspace.update(cx, |workspace, cx| { + workspace.extensions.apply(ExtensionPanelEvent::ActionLabel { + extension_id: extension_id.clone(), + action_id: action_id.into(), + label: label.into(), + }); + cx.notify(); + }); + }, + EditorHostRequest::Status(message) => { + let _ = workspace.update(cx, |workspace, cx| { + workspace.extensions.apply(ExtensionPanelEvent::Output { + extension_id: extension_id.clone(), + output: message.into(), + }); + cx.notify(); + }); + }, + EditorHostRequest::DirectoryAccess(mode, _suggested_path, reply) => { + let selection = window + .update(cx, |_, _, cx| { + cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Grant extension directory access".into()), + }) + }) + .map_err(host_access); + let result = match selection { + Ok(selection) => match selection.await { + Ok(Ok(Some(paths))) => match paths.into_iter().next() { + Some(path) => workspace + .update(cx, |workspace, _| { + workspace + .extension_service + .as_ref() + .ok_or_else(|| host_error("runtime-unavailable", "Extension runtime is unavailable"))? + .grant_directory(extension_id.as_ref(), mode, path) + }) + .map_err(host_access) + .and_then(std::convert::identity), + None => Err(host_error("directory-access-cancelled", "No directory was selected")), + }, + _ => Err(host_error("directory-access-cancelled", "Directory access was not granted")), + }, + Err(error) => Err(error), + }; + let _ = reply.send(result); + }, + } + } + if matches!(requests.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)) { host_disconnected = true; } + let terminal = workspace + .update(cx, |workspace, cx| { + let events = workspace.extension_service.as_ref().map_or_else(Vec::new, |service| service.drain_events()); + let terminal = events.iter().any(|event| matches!(event, ExtensionPanelEvent::Finished { .. } | ExtensionPanelEvent::Failed { .. } | ExtensionPanelEvent::Cancelled { .. })); + for event in events { workspace.extensions.apply(event); } + if terminal { cx.notify(); } + terminal + }) + .unwrap_or(false); + if host_disconnected && terminal { break } + Timer::after(Duration::from_millis(16)).await; + } + }) + .detach(); + } +} + +fn selection_json(snapshot: String) -> Result { + let value: serde_json::Value = serde_json::from_str(&snapshot).map_err(host_json)?; + serde_json::to_string(value.get("selection").unwrap_or(&serde_json::Value::Null)).map_err(host_json) +} + +async fn refresh_editor_from_disk( + editor: &Entity, + window: AnyWindowHandle, + cx: &mut gpui::AsyncApp, +) -> Result { + let (path, dirty) = editor + .read_with(cx, |editor, _| (editor.document_path().cloned(), editor.has_unsaved_changes())) + .map_err(host_access)?; + let path = path.ok_or_else(|| host_error("unsaved-document", "The active document has no on-disk path"))?; + if dirty { + let answer = window + .update(cx, |_, window, cx| { + window.prompt( + PromptLevel::Warning, + "Discard unsaved changes?", + Some("The extension requested a disk refresh. This replaces the editor contents and clears undo history."), + &[PromptButton::ok("Refresh"), PromptButton::cancel("Cancel")], + cx, + ) + }) + .map_err(host_access)?; + if !matches!(answer.await, Ok(0)) { + return Err(host_error("refresh-cancelled", "Document refresh was cancelled")); + } + } + let document = load_or_create_document(&path).map_err(|error| host_error("refresh-failed", error))?; + editor + .update(cx, |editor, cx| { + editor.replace_document_from_disk(document, cx); + editor.extension_snapshot_json() + }) + .map_err(host_access)? + .map_err(host_json) +} + +fn host_json(error: serde_json::Error) -> HostError { + host_error("json", error) +} + +fn no_document() -> HostError { + host_error("no-document", "No rich document is active") +} + +fn host_access(error: impl std::fmt::Display) -> HostError { + host_error("editor-unavailable", error) +} + +fn host_error(code: &str, error: impl std::fmt::Display) -> HostError { + HostError { code: code.to_owned(), message: error.to_string() } +} diff --git a/crates/flowstate/src/workspace/extensions_tool.rs b/crates/flowstate/src/workspace/extensions_tool.rs index c745bd0b..b357dac4 100644 --- a/crates/flowstate/src/workspace/extensions_tool.rs +++ b/crates/flowstate/src/workspace/extensions_tool.rs @@ -1,4 +1,4 @@ -use gpui::{AnyElement, Context, IntoElement, PromptButton, PromptLevel, SharedString, Window, div, prelude::*, px}; +use gpui::{AnyElement, AnyWindowHandle, Context, IntoElement, PromptButton, PromptLevel, SharedString, Window, div, prelude::*, px}; use gpui_component::{ ActiveTheme as _, Disableable, Icon, IconName, Selectable, Sizable, button::{Button, ButtonVariants}, @@ -194,7 +194,7 @@ impl Workspace { cx: &mut Context, ) { if !self.extensions.requires_trust(extension_id.as_ref()) { - self.extensions.invoke(extension_id.as_ref(), action_id.as_ref()); + self.start_extension_action(extension_id, action_id, window.window_handle(), cx); cx.notify(); return; } @@ -206,6 +206,7 @@ impl Workspace { &[PromptButton::ok("Trust and Run"), PromptButton::cancel("Cancel")], cx, ); + let window_handle = window.window_handle(); cx.spawn(async move |workspace, cx| { if !matches!(answer.await, Ok(0)) { return; @@ -217,7 +218,7 @@ impl Workspace { message: error, }); } else { - workspace.extensions.invoke(extension_id.as_ref(), action_id.as_ref()); + workspace.start_extension_action(extension_id, action_id, window_handle, cx); } cx.notify(); }); @@ -225,6 +226,30 @@ impl Workspace { .detach(); } + fn start_extension_action( + &mut self, + extension_id: SharedString, + action_id: SharedString, + window: AnyWindowHandle, + cx: &mut Context, + ) { + let Some(service) = self.extension_service.clone() else { + self.extensions.apply(super::super::extensions_panel::ExtensionPanelEvent::Failed { + extension_id, + message: "Extension runtime is unavailable".into(), + }); + return; + }; + let editor = self.active_editor.clone(); + let document_root = editor.as_ref().and_then(|editor| editor.read(cx).document_path().and_then(|path| path.parent().map(ToOwned::to_owned))); + let (host, requests) = super::super::extensions_panel::EditorHostBridge::bounded(32); + self.spawn_extension_host_loop(extension_id.clone(), editor, requests, window, cx); + self.extensions.invoke(extension_id.as_ref(), action_id.as_ref()); + if let Err(message) = service.invoke_with_host(extension_id.as_ref(), action_id.as_ref(), document_root, Box::new(host)) { + self.extensions.apply(super::super::extensions_panel::ExtensionPanelEvent::Failed { extension_id, message }); + } + } + fn cancel_extension(&mut self, extension_id: SharedString, cx: &mut Context) { self.extensions.cancel(extension_id.as_ref()); cx.notify(); From 3f6af7611fbece95927f9074b495efb39c1b0a18 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:39:19 -0700 Subject: [PATCH 36/54] Harden extension grants refresh and events --- .../src/workspace/extension_host_loop.rs | 43 +++++++++++++---- .../src/workspace/extensions_panel/service.rs | 47 +++++++++++++++++-- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/crates/flowstate/src/workspace/extension_host_loop.rs b/crates/flowstate/src/workspace/extension_host_loop.rs index caaa1c63..11b51d42 100644 --- a/crates/flowstate/src/workspace/extension_host_loop.rs +++ b/crates/flowstate/src/workspace/extension_host_loop.rs @@ -3,8 +3,8 @@ use std::{sync::mpsc::Receiver, time::Duration}; use flowstate_extension::HostError; use gpui::{AnyWindowHandle, Context, Entity, PathPromptOptions, PromptButton, PromptLevel, SharedString, Timer}; -use crate::rich_text_element::{RichTextEditor, load_or_create_document}; -use super::super::extensions_panel::{EditorHostRequest, ExtensionPanelEvent}; +use crate::rich_text_element::{RichTextEditor, read_document}; +use super::super::extensions_panel::{EditorHostRequest, ExtensionPanelEvent, ExtensionRunState}; use super::Workspace; impl Workspace { @@ -17,7 +17,6 @@ impl Workspace { cx: &mut Context, ) { cx.spawn(async move |workspace, cx| { - let mut host_disconnected = false; loop { while let Ok(request) = requests.try_recv() { match request { @@ -66,7 +65,34 @@ impl Workspace { cx.notify(); }); }, - EditorHostRequest::DirectoryAccess(mode, _suggested_path, reply) => { + EditorHostRequest::DirectoryAccess(mode, suggested_path, reply) => { + let access = match mode { + flowstate_extension::DirectoryAccess::Read => "read-only", + flowstate_extension::DirectoryAccess::ReadWrite => "read and write", + }; + let detail = format!( + "This extension is requesting {access} access to a directory. Suggested path: {}. The grant takes effect on its next invocation.", + suggested_path.as_deref().unwrap_or("none"), + ); + let approval = window + .update(cx, |_, window, cx| { + window.prompt( + PromptLevel::Warning, + "Grant directory access?", + Some(&detail), + &[PromptButton::ok("Choose Directory"), PromptButton::cancel("Cancel")], + cx, + ) + }) + .map_err(host_access); + let approved = match approval { + Ok(answer) => matches!(answer.await, Ok(0)), + Err(_) => false, + }; + if !approved { + let _ = reply.send(Err(host_error("directory-access-cancelled", "Directory access was not granted"))); + continue; + } let selection = window .update(cx, |_, _, cx| { cx.prompt_for_paths(PathPromptOptions { @@ -100,17 +126,16 @@ impl Workspace { }, } } - if matches!(requests.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)) { host_disconnected = true; } let terminal = workspace .update(cx, |workspace, cx| { - let events = workspace.extension_service.as_ref().map_or_else(Vec::new, |service| service.drain_events()); - let terminal = events.iter().any(|event| matches!(event, ExtensionPanelEvent::Finished { .. } | ExtensionPanelEvent::Failed { .. } | ExtensionPanelEvent::Cancelled { .. })); + let events = workspace.extension_service.as_ref().map_or_else(Vec::new, |service| service.drain_events_for(extension_id.as_ref())); for event in events { workspace.extensions.apply(event); } + let terminal = !matches!(workspace.extensions.state(extension_id.as_ref()), ExtensionRunState::Running { .. }); if terminal { cx.notify(); } terminal }) .unwrap_or(false); - if host_disconnected && terminal { break } + if terminal { break } Timer::after(Duration::from_millis(16)).await; } }) @@ -148,7 +173,7 @@ async fn refresh_editor_from_disk( return Err(host_error("refresh-cancelled", "Document refresh was cancelled")); } } - let document = load_or_create_document(&path).map_err(|error| host_error("refresh-failed", error))?; + let document = read_document(&path).map_err(|error| host_error("refresh-failed", error))?; editor .update(cx, |editor, cx| { editor.replace_document_from_disk(document, cx); diff --git a/crates/flowstate/src/workspace/extensions_panel/service.rs b/crates/flowstate/src/workspace/extensions_panel/service.rs index 47127ed2..9f30047e 100644 --- a/crates/flowstate/src/workspace/extensions_panel/service.rs +++ b/crates/flowstate/src/workspace/extensions_panel/service.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, VecDeque}, hash::{DefaultHasher, Hash, Hasher}, fs, path::PathBuf, @@ -28,6 +28,7 @@ pub struct ExtensionService { running: Arc>>, events_tx: mpsc::SyncSender, events_rx: Mutex>, + pending_events: Mutex>, } impl ExtensionService { @@ -57,6 +58,7 @@ impl ExtensionService { running: Arc::new(Mutex::new(HashMap::new())), events_tx, events_rx: Mutex::new(events_rx), + pending_events: Mutex::new(VecDeque::new()), })) } @@ -118,9 +120,19 @@ impl ExtensionService { Ok(()) } - pub fn drain_events(&self) -> Vec { - let Ok(receiver) = self.events_rx.lock() else { return Vec::new() }; - std::iter::from_fn(|| receiver.try_recv().ok()).collect() + pub fn drain_events_for(&self, extension_id: &str) -> Vec { + let Ok(mut pending) = self.pending_events.lock() else { return Vec::new() }; + let mut matched = Vec::new(); + let mut retained = VecDeque::new(); + while let Some(event) = pending.pop_front() { + if event_extension_id(&event) == extension_id { matched.push(event); } else { retained.push_back(event); } + } + *pending = retained; + let Ok(receiver) = self.events_rx.lock() else { return matched }; + while let Ok(event) = receiver.try_recv() { + if event_extension_id(&event) == extension_id { matched.push(event); } else { pending.push_back(event); } + } + matched } pub fn grant_directory( @@ -167,6 +179,17 @@ impl ExtensionService { } } +fn event_extension_id(event: &ExtensionPanelEvent) -> &str { + match event { + ExtensionPanelEvent::Started { extension_id, .. } + | ExtensionPanelEvent::ActionLabel { extension_id, .. } + | ExtensionPanelEvent::Output { extension_id, .. } + | ExtensionPanelEvent::Failed { extension_id, .. } + | ExtensionPanelEvent::Finished { extension_id } + | ExtensionPanelEvent::Cancelled { extension_id } => extension_id.as_ref(), + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] struct PersistedGrant { extension_id: String, @@ -275,3 +298,19 @@ impl ExtensionPanelAdapter for ExtensionService { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_draining_preserves_other_extensions() { + let root = tempfile::tempdir().unwrap(); + let service = ExtensionService::new(root.path().to_path_buf()).unwrap(); + service.events_tx.send(ExtensionPanelEvent::Finished { extension_id: "one".into() }).unwrap(); + service.events_tx.send(ExtensionPanelEvent::Cancelled { extension_id: "two".into() }).unwrap(); + + assert!(matches!(service.drain_events_for("one").as_slice(), [ExtensionPanelEvent::Finished { .. }])); + assert!(matches!(service.drain_events_for("two").as_slice(), [ExtensionPanelEvent::Cancelled { .. }])); + } +} From c314a34ad4fdd815938d561c058e88444b7ea8cb Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:39:49 -0700 Subject: [PATCH 37/54] Scope document directory to declared actions --- .../flowstate/src/workspace/extensions_tool.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/flowstate/src/workspace/extensions_tool.rs b/crates/flowstate/src/workspace/extensions_tool.rs index b357dac4..ab0c3c08 100644 --- a/crates/flowstate/src/workspace/extensions_tool.rs +++ b/crates/flowstate/src/workspace/extensions_tool.rs @@ -240,8 +240,24 @@ impl Workspace { }); return; }; + let requires_document = self + .extensions + .extensions() + .iter() + .find(|extension| extension.id == extension_id) + .and_then(|extension| extension.actions.iter().find(|action| action.id == action_id)) + .is_some_and(|action| action.requires_document); let editor = self.active_editor.clone(); - let document_root = editor.as_ref().and_then(|editor| editor.read(cx).document_path().and_then(|path| path.parent().map(ToOwned::to_owned))); + if requires_document && editor.is_none() { + self.extensions.apply(super::super::extensions_panel::ExtensionPanelEvent::Failed { + extension_id, + message: "Open a document before running this action".into(), + }); + return; + } + let document_root = requires_document + .then(|| editor.as_ref().and_then(|editor| editor.read(cx).document_path().and_then(|path| path.parent().map(ToOwned::to_owned)))) + .flatten(); let (host, requests) = super::super::extensions_panel::EditorHostBridge::bounded(32); self.spawn_extension_host_loop(extension_id.clone(), editor, requests, window, cx); self.extensions.invoke(extension_id.as_ref(), action_id.as_ref()); From 0028a0b8bad7d505d83c5920e6a3f88d7d8f1151 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:41:03 -0700 Subject: [PATCH 38/54] Document extension directory grants --- docs/extensions.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/extensions.md b/docs/extensions.md index 3c1a54ab..a414d51c 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -79,6 +79,12 @@ The guest receives these pre-opened directories: The runtime provides WASI clocks, randomness, HTTP, and sockets. It does not inherit Flowstate's environment or provide native process spawning. +An extension can call `request-directory-access` when it needs another folder. +Flowstate shows the requested read-only or read/write mode and lets the user +choose the directory. Approved grants are tied to the component digest and are +mounted on later invocations under the returned `/grants/` path. A +changed component cannot reuse an earlier grant without asking again. + ## Document API The imported host interface provides JSON snapshots and generation-checked edit From 996e1b45da382e143abc6493b8538902d8c0b1c2 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:58:11 -0700 Subject: [PATCH 39/54] Add all-capabilities extension scaffold --- .../extensions/all-capabilities/Cargo.lock | 359 ++++++++++++++++++ .../extensions/all-capabilities/Cargo.toml | 16 + .../extensions/all-capabilities/README.md | 32 ++ .../all-capabilities/extension.toml | 45 +++ .../all-capabilities/wit/extension.wit | 30 ++ 5 files changed, 482 insertions(+) create mode 100644 examples/extensions/all-capabilities/Cargo.lock create mode 100644 examples/extensions/all-capabilities/Cargo.toml create mode 100644 examples/extensions/all-capabilities/README.md create mode 100644 examples/extensions/all-capabilities/extension.toml create mode 100644 examples/extensions/all-capabilities/wit/extension.wit diff --git a/examples/extensions/all-capabilities/Cargo.lock b/examples/extensions/all-capabilities/Cargo.lock new file mode 100644 index 00000000..47838e0d --- /dev/null +++ b/examples/extensions/all-capabilities/Cargo.lock @@ -0,0 +1,359 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "flowstate-all-capabilities-example" +version = "0.1.0" +dependencies = [ + "getrandom", + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-encoder" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59972d6cd272259de647b7c1f1912e45e289c75ffd4be04e10695507cd7e1b59" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3f45816ef616806f48498bcd831377de578c4fa51db0c83ab8ceb78cc13523b" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19db11f87d2486580e1e8b6f494c54df7e0566b87d0b599db843c24019667339" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94c5e45f6d4cfaca727c1c48989ab3e05bb289bf84fbad226e1cfbbef2c04b7f" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cf25dc4bb1981c16aa549ec66c6762b7752bfb8b7c3b3936ae13100298dc72" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "407ee2b474af1a366766fe91b8255b7935e5e3c3afb9c304e91ac3d154287ff1" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37386eba32427684ffe37a0f765f63ce2ece03efb1ad28c23cf69562fdc67ec0" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbbd2500ac3488489ee8c6e59b79d7e47e6da5bfb019efd35d5dca57b78af624" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d997b8e5920fcbeec742b58e583325d6419a6aca617ae8075c406a61c65ba8a" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9" diff --git a/examples/extensions/all-capabilities/Cargo.toml b/examples/extensions/all-capabilities/Cargo.toml new file mode 100644 index 00000000..a892af7d --- /dev/null +++ b/examples/extensions/all-capabilities/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "flowstate-all-capabilities-example" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[workspace] + +[dependencies] +getrandom = "0.4.3" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +wit-bindgen = "0.59.0" diff --git a/examples/extensions/all-capabilities/README.md b/examples/extensions/all-capabilities/README.md new file mode 100644 index 00000000..015ebd37 --- /dev/null +++ b/examples/extensions/all-capabilities/README.md @@ -0,0 +1,32 @@ +# All-capabilities extension + +This trusted example exercises every Flowstate extension host import plus WASI +filesystem, networking, clocks, randomness, stdout, and stderr. Its destructive +buttons are labeled explicitly. Read the source before approving it. + +The WIT file is copied from Flowstate's ABI so this standalone crate can build +outside the application workspace. Keep it synchronized with +`crates/flowstate-extension/wit/extension.wit`. + +## Build and install + +```sh +rustup target add wasm32-wasip2 +cargo build --manifest-path examples/extensions/all-capabilities/Cargo.toml \ + --target wasm32-wasip2 --release + +dest="${XDG_DATA_HOME:-$HOME/.local/share}/flowstate/extensions/com.flowstate.example.all-capabilities" +install -Dm644 examples/extensions/all-capabilities/extension.toml "$dest/extension.toml" +install -Dm644 \ + examples/extensions/all-capabilities/target/wasm32-wasip2/release/flowstate_all_capabilities_example.wasm \ + "$dest/extension.wasm" +``` + +Open Flowstate's Extensions side-panel section, reload extensions, and run a +button. Directory grants become available at the returned mount path on the +next invocation. The filesystem action records its observations in +`/data/capabilities.log`; `/extension` is read-only and `/document` is available +only for document actions. + +The network action sends a plain HTTP request to `example.com:80` to demonstrate +WASI sockets without requiring TLS support in this small example. diff --git a/examples/extensions/all-capabilities/extension.toml b/examples/extensions/all-capabilities/extension.toml new file mode 100644 index 00000000..96d823e6 --- /dev/null +++ b/examples/extensions/all-capabilities/extension.toml @@ -0,0 +1,45 @@ +manifest_version = 1 +id = "com.flowstate.example.all-capabilities" +name = "All Capabilities Example" +version = "0.1.0" +component = "extension.wasm" + +[[actions]] +id = "inspect" +label = "Inspect document and selection" +requires_document = true + +[[actions]] +id = "replace-selection" +label = "Replace selection (destructive)" +requires_document = true + +[[actions]] +id = "insert-blocks" +label = "Insert styled blocks (destructive)" +requires_document = true + +[[actions]] +id = "refresh" +label = "Refresh from disk (destructive)" +requires_document = true + +[[actions]] +id = "filesystem" +label = "Exercise scoped filesystems" +requires_document = true + +[[actions]] +id = "network" +label = "Fetch example.com" +requires_document = false + +[[actions]] +id = "request-directory" +label = "Request directory access" +requires_document = false + +[[actions]] +id = "runtime" +label = "Exercise clock, random, and output" +requires_document = false diff --git a/examples/extensions/all-capabilities/wit/extension.wit b/examples/extensions/all-capabilities/wit/extension.wit new file mode 100644 index 00000000..3f5c9194 --- /dev/null +++ b/examples/extensions/all-capabilities/wit/extension.wit @@ -0,0 +1,30 @@ +package flowstate:extension@1.0.0; + +interface host { + record host-error { + code: string, + message: string, + } + enum directory-access { read, read-write } + record directory-grant { + grant-id: string, + mount-path: string, + available-next-invocation: bool, + } + + snapshot: func() -> result; + selection: func() -> result; + apply-edits: func(edits-json: string) -> result; + refresh-from-disk: func() -> result; + set-action-label: func(action-id: string, label: string) -> result<_, host-error>; + set-status: func(message: string); + request-directory-access: func( + mode: directory-access, + suggested-path: option, + ) -> result; +} + +world extension { + import host; + export run: func(action-id: string) -> result<_, string>; +} From 396a09bbaf1423bcf8a26dbc1fce283ab87cd77f Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:58:51 -0700 Subject: [PATCH 40/54] Add extension action dispatch and document edits --- .../all-capabilities/src/actions/document.rs | 80 +++++++++++++++++++ .../all-capabilities/src/actions/mod.rs | 27 +++++++ .../all-capabilities/src/bindings.rs | 6 ++ .../extensions/all-capabilities/src/lib.rs | 12 +++ 4 files changed, 125 insertions(+) create mode 100644 examples/extensions/all-capabilities/src/actions/document.rs create mode 100644 examples/extensions/all-capabilities/src/actions/mod.rs create mode 100644 examples/extensions/all-capabilities/src/bindings.rs create mode 100644 examples/extensions/all-capabilities/src/lib.rs diff --git a/examples/extensions/all-capabilities/src/actions/document.rs b/examples/extensions/all-capabilities/src/actions/document.rs new file mode 100644 index 00000000..7c206200 --- /dev/null +++ b/examples/extensions/all-capabilities/src/actions/document.rs @@ -0,0 +1,80 @@ +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::host_error; +use crate::bindings::flowstate::extension::host; + +#[derive(Deserialize)] +struct Snapshot { + generation: u64, + document: Document, + selection: Selection, +} + +#[derive(Deserialize)] +struct Document { + blocks: Vec, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum Selection { + Text { anchor: Offset, head: Offset }, + #[serde(other)] + Other, +} + +#[derive(Deserialize, serde::Serialize)] +struct Offset { + paragraph: usize, + byte: usize, +} + +fn snapshot() -> Result { + let source = host::snapshot().map_err(host_error)?; + serde_json::from_str(&source).map_err(|error| format!("invalid snapshot: {error}")) +} + +pub fn inspect() -> Result<(), String> { + let snapshot = host::snapshot().map_err(host_error)?; + let selection = host::selection().map_err(host_error)?; + host::set_status(&format!("snapshot={} bytes; selection={selection}", snapshot.len())); + host::set_action_label("inspect", "Inspect again").map_err(host_error) +} + +pub fn replace_selection() -> Result<(), String> { + let snapshot = snapshot()?; + let Selection::Text { anchor, head } = snapshot.selection else { + return Err("select text before running this action".into()); + }; + let request = json!({ + "expected_generation": snapshot.generation, + "edits": [{ + "kind": "replace_text", + "start": anchor, + "end": head, + "fragment": styled_fragment("Replaced by the example extension"), + }], + }); + host::apply_edits(&request.to_string()).map_err(host_error)?; + Ok(()) +} + +fn styled_fragment(text: &str) -> Value { + json!({ + "format": "gpui-flowtext.rich-text-fragment.v1", + "paragraphs": [{ + "style": "Normal", + "runs": [{ "text": text, "styles": { + "semantic": { "Custom": 1 }, "direct_underline": true, + "strikethrough": false, "highlight": { "Custom": 1 } + }}] + }], + "blocks": [], "assets": [] + }) +} + +pub fn refresh() -> Result<(), String> { + host::refresh_from_disk().map_err(host_error)?; + Ok(()) +} diff --git a/examples/extensions/all-capabilities/src/actions/mod.rs b/examples/extensions/all-capabilities/src/actions/mod.rs new file mode 100644 index 00000000..eee047f8 --- /dev/null +++ b/examples/extensions/all-capabilities/src/actions/mod.rs @@ -0,0 +1,27 @@ +mod document; +mod filesystem; +mod network; +mod runtime; + +use crate::bindings::flowstate::extension::host::{self, HostError}; + +pub fn run(action_id: &str) -> Result<(), String> { + host::set_status(&format!("Running {action_id}…")); + let result = match action_id { + "inspect" => document::inspect(), + "replace-selection" => document::replace_selection(), + "insert-blocks" => document::insert_blocks(), + "refresh" => document::refresh(), + "filesystem" => filesystem::exercise(), + "network" => network::fetch_example(), + "request-directory" => filesystem::request_directory(), + "runtime" => runtime::exercise(), + _ => Err(format!("unknown action: {action_id}")), + }; + host::set_status(if result.is_ok() { "Finished" } else { "Failed" }); + result +} + +pub(super) fn host_error(error: HostError) -> String { + format!("{}: {}", error.code, error.message) +} diff --git a/examples/extensions/all-capabilities/src/bindings.rs b/examples/extensions/all-capabilities/src/bindings.rs new file mode 100644 index 00000000..b055700d --- /dev/null +++ b/examples/extensions/all-capabilities/src/bindings.rs @@ -0,0 +1,6 @@ +wit_bindgen::generate!({ + world: "extension", + path: "wit", +}); + +pub use exports::flowstate::extension::extension::Guest; diff --git a/examples/extensions/all-capabilities/src/lib.rs b/examples/extensions/all-capabilities/src/lib.rs new file mode 100644 index 00000000..20349541 --- /dev/null +++ b/examples/extensions/all-capabilities/src/lib.rs @@ -0,0 +1,12 @@ +mod actions; +mod bindings; + +struct Extension; + +impl bindings::Guest for Extension { + fn run(action_id: String) -> Result<(), String> { + actions::run(&action_id) + } +} + +bindings::export!(Extension with_types_in bindings); From d9df4a1ec43e5341f198fc4fd7608e0447f43eb3 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:59:14 -0700 Subject: [PATCH 41/54] Demonstrate rich document block insertion --- .../all-capabilities/src/actions/document.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/examples/extensions/all-capabilities/src/actions/document.rs b/examples/extensions/all-capabilities/src/actions/document.rs index 7c206200..c6978ab9 100644 --- a/examples/extensions/all-capabilities/src/actions/document.rs +++ b/examples/extensions/all-capabilities/src/actions/document.rs @@ -78,3 +78,57 @@ pub fn refresh() -> Result<(), String> { host::refresh_from_disk().map_err(host_error)?; Ok(()) } + +pub fn insert_blocks() -> Result<(), String> { + let snapshot = snapshot()?; + let mut random = [0_u8; 8]; + getrandom::fill(&mut random).map_err(|error| error.to_string())?; + let asset_id = u64::from_le_bytes(random); + let svg = br#" +Wasm"#; + let paragraph = json!({ "Paragraph": { + "style": { "Custom": 1 }, + "runs": [{ "text": "Styled extension paragraph", "styles": { + "semantic": { "Custom": 2 }, "direct_underline": false, + "strikethrough": true, "highlight": null + }}] + }}); + let equation = json!({ "Equation": { + "source": "x^2 + y^2 = z^2", "syntax": "Latex", "display": "Display" + }}); + let table = json!({ "Table": { + "rows": [{ "cells": [ + { "blocks": [{ "Paragraph": plain_paragraph("A") }], "row_span": 1, "col_span": 1 }, + { "blocks": [{ "Paragraph": plain_paragraph("B") }], "row_span": 1, "col_span": 1 } + ]}], + "column_widths": ["Auto", "Auto"], "style": { "header_row": false } + }}); + let image = json!({ "Image": { + "asset_id": asset_id, "alt_text": "Generated by the example extension", + "caption": null, "sizing": "Intrinsic", "alignment": "Center" + }}); + let request = json!({ + "expected_generation": snapshot.generation, + "edits": [{ + "kind": "splice_blocks", + "start": snapshot.document.blocks.len(), "end": snapshot.document.blocks.len(), + "blocks": [paragraph, equation, table, image], + "assets": [{ + "id": asset_id, "mime_type": "image/svg+xml", "original_name": "wasm.svg", + "content_hash": asset_id, "bytes": svg + }] + }] + }); + host::apply_edits(&request.to_string()).map_err(host_error)?; + Ok(()) +} + +fn plain_paragraph(text: &str) -> Value { + json!({ + "style": "Normal", + "runs": [{ "text": text, "styles": { + "semantic": "Plain", "direct_underline": false, + "strikethrough": false, "highlight": null + }}] + }) +} From c18d416f79786f8ff5c53a9f31232c1cbecc436b Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 22:59:44 -0700 Subject: [PATCH 42/54] Exercise WASI and directory capabilities --- .../src/actions/filesystem.rs | 41 +++++++++++++++++++ .../all-capabilities/src/actions/network.rs | 24 +++++++++++ .../all-capabilities/src/actions/runtime.rs | 16 ++++++++ 3 files changed, 81 insertions(+) create mode 100644 examples/extensions/all-capabilities/src/actions/filesystem.rs create mode 100644 examples/extensions/all-capabilities/src/actions/network.rs create mode 100644 examples/extensions/all-capabilities/src/actions/runtime.rs diff --git a/examples/extensions/all-capabilities/src/actions/filesystem.rs b/examples/extensions/all-capabilities/src/actions/filesystem.rs new file mode 100644 index 00000000..3f2bb8c3 --- /dev/null +++ b/examples/extensions/all-capabilities/src/actions/filesystem.rs @@ -0,0 +1,41 @@ +use std::fs; +use std::path::Path; + +use super::host_error; +use crate::bindings::flowstate::extension::host::{self, DirectoryAccess}; + +pub fn exercise() -> Result<(), String> { + let manifest = fs::read_to_string("/extension/extension.toml") + .map_err(|error| format!("read /extension: {error}"))?; + let document_entries = list("/document"); + let grant_entries = list("/grants"); + let line = format!( + "manifest={} bytes; /document={document_entries:?}; /grants={grant_entries:?}\n", + manifest.len() + ); + fs::create_dir_all("/data").map_err(|error| format!("create /data: {error}"))?; + fs::write("/data/capabilities.log", &line) + .map_err(|error| format!("write /data: {error}"))?; + host::set_status(&line); + Ok(()) +} + +pub fn request_directory() -> Result<(), String> { + let grant = host::request_directory_access(DirectoryAccess::ReadWrite, None) + .map_err(host_error)?; + host::set_status(&format!( + "Grant {} mounts at {} (next invocation: {})", + grant.grant_id, grant.mount_path, grant.available_next_invocation + )); + host::set_action_label("request-directory", "Request another directory") + .map_err(host_error) +} + +fn list(path: &str) -> Vec { + fs::read_dir(Path::new(path)) + .into_iter() + .flatten() + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect() +} diff --git a/examples/extensions/all-capabilities/src/actions/network.rs b/examples/extensions/all-capabilities/src/actions/network.rs new file mode 100644 index 00000000..caefa8f3 --- /dev/null +++ b/examples/extensions/all-capabilities/src/actions/network.rs @@ -0,0 +1,24 @@ +use std::io::{Read, Write}; +use std::net::TcpStream; + +use crate::bindings::flowstate::extension::host; + +pub fn fetch_example() -> Result<(), String> { + let mut stream = TcpStream::connect("example.com:80") + .map_err(|error| format!("connect: {error}"))?; + stream + .write_all(b"GET / HTTP/1.0\r\nHost: example.com\r\nConnection: close\r\n\r\n") + .map_err(|error| format!("send: {error}"))?; + let mut response = Vec::new(); + stream + .take(64 * 1024) + .read_to_end(&mut response) + .map_err(|error| format!("receive: {error}"))?; + let status_line = String::from_utf8_lossy(&response) + .lines() + .next() + .unwrap_or("empty response") + .to_owned(); + host::set_status(&format!("Network response: {status_line}")); + Ok(()) +} diff --git a/examples/extensions/all-capabilities/src/actions/runtime.rs b/examples/extensions/all-capabilities/src/actions/runtime.rs new file mode 100644 index 00000000..0fdaf2b4 --- /dev/null +++ b/examples/extensions/all-capabilities/src/actions/runtime.rs @@ -0,0 +1,16 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::bindings::flowstate::extension::host; + +pub fn exercise() -> Result<(), String> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| error.to_string())?; + let mut random = [0_u8; 16]; + getrandom::fill(&mut random).map_err(|error| error.to_string())?; + let message = format!("time={}ms random={random:02x?}", now.as_millis()); + println!("all-capabilities stdout: {message}"); + eprintln!("all-capabilities stderr: captured output is working"); + host::set_status(&message); + Ok(()) +} From 1b64127380e123505f27128becc2cae4cc8df3ae Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:02:37 -0700 Subject: [PATCH 43/54] Cover destructive extension actions --- .../extensions/all-capabilities/.gitignore | 1 + .../all-capabilities/extension.toml | 20 +++++++ .../all-capabilities/src/actions/blocks.rs | 55 +++++++++++++++++++ .../all-capabilities/src/actions/table.rs | 45 +++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 examples/extensions/all-capabilities/.gitignore create mode 100644 examples/extensions/all-capabilities/src/actions/blocks.rs create mode 100644 examples/extensions/all-capabilities/src/actions/table.rs diff --git a/examples/extensions/all-capabilities/.gitignore b/examples/extensions/all-capabilities/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/examples/extensions/all-capabilities/.gitignore @@ -0,0 +1 @@ +/target diff --git a/examples/extensions/all-capabilities/extension.toml b/examples/extensions/all-capabilities/extension.toml index 96d823e6..48efb3b4 100644 --- a/examples/extensions/all-capabilities/extension.toml +++ b/examples/extensions/all-capabilities/extension.toml @@ -19,6 +19,16 @@ id = "insert-blocks" label = "Insert styled blocks (destructive)" requires_document = true +[[actions]] +id = "delete-selection" +label = "Delete selection (destructive)" +requires_document = true + +[[actions]] +id = "replace-table-cell" +label = "Replace selected table cell (destructive)" +requires_document = true + [[actions]] id = "refresh" label = "Refresh from disk (destructive)" @@ -43,3 +53,13 @@ requires_document = false id = "runtime" label = "Exercise clock, random, and output" requires_document = false + +[[actions]] +id = "access-last-grant" +label = "Write to last directory grant" +requires_document = false + +[[actions]] +id = "cancellable-loop" +label = "Run until cancelled" +requires_document = false diff --git a/examples/extensions/all-capabilities/src/actions/blocks.rs b/examples/extensions/all-capabilities/src/actions/blocks.rs new file mode 100644 index 00000000..eb5632a7 --- /dev/null +++ b/examples/extensions/all-capabilities/src/actions/blocks.rs @@ -0,0 +1,55 @@ +use serde_json::{Value, json}; + +use super::host_error; +use crate::bindings::flowstate::extension::host; + +pub fn insert() -> Result<(), String> { + let snapshot: Value = serde_json::from_str(&host::snapshot().map_err(host_error)?).map_err(|error| error.to_string())?; + let generation = snapshot["generation"] + .as_u64() + .ok_or("missing generation")?; + let end = snapshot["document"]["blocks"] + .as_array() + .ok_or("missing blocks")? + .len(); + let mut random = [0_u8; 8]; + getrandom::fill(&mut random).map_err(|error| error.to_string())?; + let asset_id = u64::from_le_bytes(random); + let svg: &[u8] = br#" +Wasm"#; + let paragraph = json!({ "Paragraph": { + "style": { "Custom": 1 }, "runs": [{ "text": "Styled extension paragraph", "styles": { + "semantic": { "Custom": 2 }, "direct_underline": false, + "strikethrough": true, "highlight": null + }}] + }}); + let equation = json!({ "Equation": { + "source": "x^2 + y^2 = z^2", "syntax": "Latex", "display": "Display" + }}); + let table = json!({ "Table": { + "rows": [{ "cells": [ + { "blocks": [{ "Paragraph": plain_paragraph("A") }], "row_span": 1, "col_span": 1 }, + { "blocks": [{ "Paragraph": plain_paragraph("B") }], "row_span": 1, "col_span": 1 } + ]}], "column_widths": ["Auto", "Auto"], "style": { "header_row": false } + }}); + let image = json!({ "Image": { + "asset_id": asset_id, "alt_text": "Generated by the example extension", + "caption": null, "sizing": "Intrinsic", "alignment": "Center" + }}); + let request = json!({ "expected_generation": generation, "edits": [{ + "kind": "splice_blocks", "start": end, "end": end, + "blocks": [paragraph, equation, table, image], "assets": [{ + "id": asset_id, "mime_type": "image/svg+xml", "original_name": "wasm.svg", + "content_hash": asset_id, "bytes": svg + }] + }]}); + host::apply_edits(&request.to_string()).map_err(host_error)?; + Ok(()) +} + +fn plain_paragraph(text: &str) -> Value { + json!({ "style": "Normal", "runs": [{ "text": text, "styles": { + "semantic": "Plain", "direct_underline": false, + "strikethrough": false, "highlight": null + }}]}) +} diff --git a/examples/extensions/all-capabilities/src/actions/table.rs b/examples/extensions/all-capabilities/src/actions/table.rs new file mode 100644 index 00000000..7ac066bd --- /dev/null +++ b/examples/extensions/all-capabilities/src/actions/table.rs @@ -0,0 +1,45 @@ +use serde::Deserialize; +use serde_json::json; + +use super::host_error; +use crate::bindings::flowstate::extension::host; + +#[derive(Deserialize)] +struct Snapshot { + generation: u64, + selection: Selection, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum Selection { + TableCell { + block_ix: usize, + row_ix: usize, + cell_ix: usize, + }, + #[serde(other)] + Other, +} + +pub fn replace_selected_cell() -> Result<(), String> { + let source = host::snapshot().map_err(host_error)?; + let snapshot: Snapshot = serde_json::from_str(&source).map_err(|error| error.to_string())?; + let Selection::TableCell { block_ix, row_ix, cell_ix } = snapshot.selection else { + return Err("select a table cell before running this action".into()); + }; + let request = json!({ "expected_generation": snapshot.generation, "edits": [{ + "kind": "replace_table_cell", "block_ix": block_ix, + "row_ix": row_ix, "cell_ix": cell_ix, + "blocks": [{ "Paragraph": { + "style": "Normal", "runs": [{ + "text": "Replaced table cell", "styles": { + "semantic": "Plain", "direct_underline": true, + "strikethrough": false, "highlight": null + } + }] + }}] + }]}); + host::apply_edits(&request.to_string()).map_err(host_error)?; + Ok(()) +} From 77160bfc5b5db53ff8ccc0b49f4a0783eb3071db Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:02:42 -0700 Subject: [PATCH 44/54] Finalize guest bindings and runtime demos --- .../all-capabilities/src/actions/network.rs | 33 +++++++++---------- .../all-capabilities/src/actions/runtime.rs | 27 +++++++++------ .../all-capabilities/src/bindings.rs | 6 ++-- .../extensions/all-capabilities/src/lib.rs | 6 ++-- 4 files changed, 38 insertions(+), 34 deletions(-) diff --git a/examples/extensions/all-capabilities/src/actions/network.rs b/examples/extensions/all-capabilities/src/actions/network.rs index caefa8f3..0632f5ec 100644 --- a/examples/extensions/all-capabilities/src/actions/network.rs +++ b/examples/extensions/all-capabilities/src/actions/network.rs @@ -4,21 +4,20 @@ use std::net::TcpStream; use crate::bindings::flowstate::extension::host; pub fn fetch_example() -> Result<(), String> { - let mut stream = TcpStream::connect("example.com:80") - .map_err(|error| format!("connect: {error}"))?; - stream - .write_all(b"GET / HTTP/1.0\r\nHost: example.com\r\nConnection: close\r\n\r\n") - .map_err(|error| format!("send: {error}"))?; - let mut response = Vec::new(); - stream - .take(64 * 1024) - .read_to_end(&mut response) - .map_err(|error| format!("receive: {error}"))?; - let status_line = String::from_utf8_lossy(&response) - .lines() - .next() - .unwrap_or("empty response") - .to_owned(); - host::set_status(&format!("Network response: {status_line}")); - Ok(()) + let mut stream = TcpStream::connect("example.com:80").map_err(|error| format!("connect: {error}"))?; + stream + .write_all(b"GET / HTTP/1.0\r\nHost: example.com\r\nConnection: close\r\n\r\n") + .map_err(|error| format!("send: {error}"))?; + let mut response = Vec::new(); + stream + .take(64 * 1024) + .read_to_end(&mut response) + .map_err(|error| format!("receive: {error}"))?; + let status_line = String::from_utf8_lossy(&response) + .lines() + .next() + .unwrap_or("empty response") + .to_owned(); + host::set_status(&format!("Network response: {status_line}")); + Ok(()) } diff --git a/examples/extensions/all-capabilities/src/actions/runtime.rs b/examples/extensions/all-capabilities/src/actions/runtime.rs index 0fdaf2b4..7b266a22 100644 --- a/examples/extensions/all-capabilities/src/actions/runtime.rs +++ b/examples/extensions/all-capabilities/src/actions/runtime.rs @@ -3,14 +3,21 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::bindings::flowstate::extension::host; pub fn exercise() -> Result<(), String> { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| error.to_string())?; - let mut random = [0_u8; 16]; - getrandom::fill(&mut random).map_err(|error| error.to_string())?; - let message = format!("time={}ms random={random:02x?}", now.as_millis()); - println!("all-capabilities stdout: {message}"); - eprintln!("all-capabilities stderr: captured output is working"); - host::set_status(&message); - Ok(()) + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| error.to_string())?; + let mut random = [0_u8; 16]; + getrandom::fill(&mut random).map_err(|error| error.to_string())?; + let message = format!("time={}ms random={random:02x?}", now.as_millis()); + println!("all-capabilities stdout: {message}"); + eprintln!("all-capabilities stderr: captured output is working"); + host::set_status(&message); + Ok(()) +} + +pub fn cancellable_loop() -> Result<(), String> { + host::set_status("Loop is running; press Cancel to interrupt it"); + loop { + std::hint::spin_loop(); + } } diff --git a/examples/extensions/all-capabilities/src/bindings.rs b/examples/extensions/all-capabilities/src/bindings.rs index b055700d..a606fd57 100644 --- a/examples/extensions/all-capabilities/src/bindings.rs +++ b/examples/extensions/all-capabilities/src/bindings.rs @@ -1,6 +1,4 @@ wit_bindgen::generate!({ - world: "extension", - path: "wit", + world: "extension", + path: "wit", }); - -pub use exports::flowstate::extension::extension::Guest; diff --git a/examples/extensions/all-capabilities/src/lib.rs b/examples/extensions/all-capabilities/src/lib.rs index 20349541..a9e02412 100644 --- a/examples/extensions/all-capabilities/src/lib.rs +++ b/examples/extensions/all-capabilities/src/lib.rs @@ -4,9 +4,9 @@ mod bindings; struct Extension; impl bindings::Guest for Extension { - fn run(action_id: String) -> Result<(), String> { - actions::run(&action_id) - } + fn run(action_id: String) -> Result<(), String> { + actions::run(&action_id) + } } bindings::export!(Extension with_types_in bindings); From f70ed3197ebf0524c38918fff2b6b4422738d8d0 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:02:42 -0700 Subject: [PATCH 45/54] Persist and exercise directory grants --- .../src/actions/filesystem.rs | 55 ++++++++++--------- .../all-capabilities/src/actions/mod.rs | 36 +++++++----- 2 files changed, 49 insertions(+), 42 deletions(-) diff --git a/examples/extensions/all-capabilities/src/actions/filesystem.rs b/examples/extensions/all-capabilities/src/actions/filesystem.rs index 3f2bb8c3..fee6cfa4 100644 --- a/examples/extensions/all-capabilities/src/actions/filesystem.rs +++ b/examples/extensions/all-capabilities/src/actions/filesystem.rs @@ -5,37 +5,38 @@ use super::host_error; use crate::bindings::flowstate::extension::host::{self, DirectoryAccess}; pub fn exercise() -> Result<(), String> { - let manifest = fs::read_to_string("/extension/extension.toml") - .map_err(|error| format!("read /extension: {error}"))?; - let document_entries = list("/document"); - let grant_entries = list("/grants"); - let line = format!( - "manifest={} bytes; /document={document_entries:?}; /grants={grant_entries:?}\n", - manifest.len() - ); - fs::create_dir_all("/data").map_err(|error| format!("create /data: {error}"))?; - fs::write("/data/capabilities.log", &line) - .map_err(|error| format!("write /data: {error}"))?; - host::set_status(&line); - Ok(()) + let manifest = fs::read_to_string("/extension/extension.toml").map_err(|error| format!("read /extension: {error}"))?; + let document_entries = list("/document"); + let line = format!("manifest={} bytes; /document={document_entries:?}\n", manifest.len()); + fs::create_dir_all("/data").map_err(|error| format!("create /data: {error}"))?; + fs::write("/data/capabilities.log", &line).map_err(|error| format!("write /data: {error}"))?; + host::set_status(&line); + Ok(()) } pub fn request_directory() -> Result<(), String> { - let grant = host::request_directory_access(DirectoryAccess::ReadWrite, None) - .map_err(host_error)?; - host::set_status(&format!( - "Grant {} mounts at {} (next invocation: {})", - grant.grant_id, grant.mount_path, grant.available_next_invocation - )); - host::set_action_label("request-directory", "Request another directory") - .map_err(host_error) + let grant = host::request_directory_access(DirectoryAccess::ReadWrite, None).map_err(host_error)?; + fs::write("/data/last-grant", &grant.mount_path).map_err(|error| format!("remember grant: {error}"))?; + host::set_status(&format!( + "Grant {} mounts at {} (next invocation: {})", + grant.grant_id, grant.mount_path, grant.available_next_invocation + )); + host::set_action_label("request-directory", "Request another directory").map_err(host_error) +} + +pub fn access_last_grant() -> Result<(), String> { + let mount = fs::read_to_string("/data/last-grant").map_err(|error| format!("read saved grant: {error}"))?; + let path = Path::new(mount.trim()).join("flowstate-extension-example.txt"); + fs::write(&path, "Written by Flowstate's all-capabilities example.\n").map_err(|error| format!("write {}: {error}", path.display()))?; + host::set_status(&format!("Wrote {}", path.display())); + Ok(()) } fn list(path: &str) -> Vec { - fs::read_dir(Path::new(path)) - .into_iter() - .flatten() - .filter_map(Result::ok) - .filter_map(|entry| entry.file_name().into_string().ok()) - .collect() + fs::read_dir(Path::new(path)) + .into_iter() + .flatten() + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect() } diff --git a/examples/extensions/all-capabilities/src/actions/mod.rs b/examples/extensions/all-capabilities/src/actions/mod.rs index eee047f8..e3bef634 100644 --- a/examples/extensions/all-capabilities/src/actions/mod.rs +++ b/examples/extensions/all-capabilities/src/actions/mod.rs @@ -1,27 +1,33 @@ +mod blocks; mod document; mod filesystem; mod network; mod runtime; +mod table; use crate::bindings::flowstate::extension::host::{self, HostError}; pub fn run(action_id: &str) -> Result<(), String> { - host::set_status(&format!("Running {action_id}…")); - let result = match action_id { - "inspect" => document::inspect(), - "replace-selection" => document::replace_selection(), - "insert-blocks" => document::insert_blocks(), - "refresh" => document::refresh(), - "filesystem" => filesystem::exercise(), - "network" => network::fetch_example(), - "request-directory" => filesystem::request_directory(), - "runtime" => runtime::exercise(), - _ => Err(format!("unknown action: {action_id}")), - }; - host::set_status(if result.is_ok() { "Finished" } else { "Failed" }); - result + host::set_status(&format!("Running {action_id}…")); + let result = match action_id { + "inspect" => document::inspect(), + "replace-selection" => document::replace_selection(), + "delete-selection" => document::delete_selection(), + "insert-blocks" => blocks::insert(), + "replace-table-cell" => table::replace_selected_cell(), + "refresh" => document::refresh(), + "filesystem" => filesystem::exercise(), + "network" => network::fetch_example(), + "request-directory" => filesystem::request_directory(), + "access-last-grant" => filesystem::access_last_grant(), + "runtime" => runtime::exercise(), + "cancellable-loop" => runtime::cancellable_loop(), + _ => Err(format!("unknown action: {action_id}")), + }; + host::set_status(if result.is_ok() { "Finished" } else { "Failed" }); + result } pub(super) fn host_error(error: HostError) -> String { - format!("{}: {}", error.code, error.message) + format!("{}: {}", error.code, error.message) } From d15a492f04d77f0d841fb240966eb2ed202eeca2 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:03:17 -0700 Subject: [PATCH 46/54] Handle normalized selection edits and tests --- .../all-capabilities/src/actions/document.rs | 182 ++++++++---------- 1 file changed, 83 insertions(+), 99 deletions(-) diff --git a/examples/extensions/all-capabilities/src/actions/document.rs b/examples/extensions/all-capabilities/src/actions/document.rs index c6978ab9..8565eee7 100644 --- a/examples/extensions/all-capabilities/src/actions/document.rs +++ b/examples/extensions/all-capabilities/src/actions/document.rs @@ -6,129 +6,113 @@ use crate::bindings::flowstate::extension::host; #[derive(Deserialize)] struct Snapshot { - generation: u64, - document: Document, - selection: Selection, -} - -#[derive(Deserialize)] -struct Document { - blocks: Vec, + generation: u64, + selection: Selection, } #[derive(Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] enum Selection { - Text { anchor: Offset, head: Offset }, - #[serde(other)] - Other, + Text { + anchor: Offset, + head: Offset, + }, + #[serde(other)] + Other, } -#[derive(Deserialize, serde::Serialize)] +#[derive(Clone, Deserialize, Eq, Ord, PartialEq, PartialOrd, serde::Serialize)] struct Offset { - paragraph: usize, - byte: usize, + paragraph: usize, + byte: usize, } fn snapshot() -> Result { - let source = host::snapshot().map_err(host_error)?; - serde_json::from_str(&source).map_err(|error| format!("invalid snapshot: {error}")) + let source = host::snapshot().map_err(host_error)?; + serde_json::from_str(&source).map_err(|error| format!("invalid snapshot: {error}")) } pub fn inspect() -> Result<(), String> { - let snapshot = host::snapshot().map_err(host_error)?; - let selection = host::selection().map_err(host_error)?; - host::set_status(&format!("snapshot={} bytes; selection={selection}", snapshot.len())); - host::set_action_label("inspect", "Inspect again").map_err(host_error) + let snapshot = host::snapshot().map_err(host_error)?; + let selection = host::selection().map_err(host_error)?; + host::set_status(&format!("snapshot={} bytes; selection={selection}", snapshot.len())); + host::set_action_label("inspect", "Inspect again").map_err(host_error) } pub fn replace_selection() -> Result<(), String> { - let snapshot = snapshot()?; - let Selection::Text { anchor, head } = snapshot.selection else { - return Err("select text before running this action".into()); - }; - let request = json!({ - "expected_generation": snapshot.generation, - "edits": [{ - "kind": "replace_text", - "start": anchor, - "end": head, - "fragment": styled_fragment("Replaced by the example extension"), - }], - }); - host::apply_edits(&request.to_string()).map_err(host_error)?; - Ok(()) + let snapshot = snapshot()?; + let Selection::Text { anchor, head } = snapshot.selection else { + return Err("select text before running this action".into()); + }; + let (start, end) = ordered(anchor, head); + let request = json!({ + "expected_generation": snapshot.generation, + "edits": [{ + "kind": "replace_text", + "start": start, + "end": end, + "fragment": styled_fragment("Replaced by the example extension"), + }], + }); + host::apply_edits(&request.to_string()).map_err(host_error)?; + Ok(()) +} + +pub fn delete_selection() -> Result<(), String> { + let snapshot = snapshot()?; + let Selection::Text { anchor, head } = snapshot.selection else { + return Err("select text before running this action".into()); + }; + let (start, end) = ordered(anchor, head); + let request = json!({ "expected_generation": snapshot.generation, "edits": [{ + "kind": "replace_text", "start": start, "end": end, + "fragment": { "format": "gpui-flowtext.rich-text-fragment.v1", + "paragraphs": [], "blocks": [], "assets": [] } + }]}); + host::apply_edits(&request.to_string()).map_err(host_error)?; + Ok(()) +} + +fn ordered(anchor: Offset, head: Offset) -> (Offset, Offset) { + if anchor <= head { (anchor, head) } else { (head, anchor) } } fn styled_fragment(text: &str) -> Value { - json!({ - "format": "gpui-flowtext.rich-text-fragment.v1", - "paragraphs": [{ - "style": "Normal", - "runs": [{ "text": text, "styles": { - "semantic": { "Custom": 1 }, "direct_underline": true, - "strikethrough": false, "highlight": { "Custom": 1 } - }}] - }], - "blocks": [], "assets": [] - }) + json!({ + "format": "gpui-flowtext.rich-text-fragment.v1", + "paragraphs": [{ + "style": "Normal", + "runs": [{ "text": text, "styles": { + "semantic": { "Custom": 1 }, "direct_underline": true, + "strikethrough": false, "highlight": { "Custom": 1 } + }}] + }], + "blocks": [], "assets": [] + }) } pub fn refresh() -> Result<(), String> { - host::refresh_from_disk().map_err(host_error)?; - Ok(()) + host::refresh_from_disk().map_err(host_error)?; + Ok(()) } -pub fn insert_blocks() -> Result<(), String> { - let snapshot = snapshot()?; - let mut random = [0_u8; 8]; - getrandom::fill(&mut random).map_err(|error| error.to_string())?; - let asset_id = u64::from_le_bytes(random); - let svg = br#" -Wasm"#; - let paragraph = json!({ "Paragraph": { - "style": { "Custom": 1 }, - "runs": [{ "text": "Styled extension paragraph", "styles": { - "semantic": { "Custom": 2 }, "direct_underline": false, - "strikethrough": true, "highlight": null - }}] - }}); - let equation = json!({ "Equation": { - "source": "x^2 + y^2 = z^2", "syntax": "Latex", "display": "Display" - }}); - let table = json!({ "Table": { - "rows": [{ "cells": [ - { "blocks": [{ "Paragraph": plain_paragraph("A") }], "row_span": 1, "col_span": 1 }, - { "blocks": [{ "Paragraph": plain_paragraph("B") }], "row_span": 1, "col_span": 1 } - ]}], - "column_widths": ["Auto", "Auto"], "style": { "header_row": false } - }}); - let image = json!({ "Image": { - "asset_id": asset_id, "alt_text": "Generated by the example extension", - "caption": null, "sizing": "Intrinsic", "alignment": "Center" - }}); - let request = json!({ - "expected_generation": snapshot.generation, - "edits": [{ - "kind": "splice_blocks", - "start": snapshot.document.blocks.len(), "end": snapshot.document.blocks.len(), - "blocks": [paragraph, equation, table, image], - "assets": [{ - "id": asset_id, "mime_type": "image/svg+xml", "original_name": "wasm.svg", - "content_hash": asset_id, "bytes": svg - }] - }] - }); - host::apply_edits(&request.to_string()).map_err(host_error)?; - Ok(()) -} +#[cfg(test)] +mod tests { + use super::{Offset, ordered, styled_fragment}; + + #[test] + fn reversed_selection_is_normalized() { + let first = Offset { paragraph: 1, byte: 2 }; + let second = Offset { paragraph: 0, byte: 9 }; + let (start, end) = ordered(first, second); + assert_eq!((start.paragraph, start.byte), (0, 9)); + assert_eq!((end.paragraph, end.byte), (1, 2)); + } -fn plain_paragraph(text: &str) -> Value { - json!({ - "style": "Normal", - "runs": [{ "text": text, "styles": { - "semantic": "Plain", "direct_underline": false, - "strikethrough": false, "highlight": null - }}] - }) + #[test] + fn styled_fragment_uses_flowstate_clipboard_format() { + let fragment = styled_fragment("hello"); + assert_eq!(fragment["format"], "gpui-flowtext.rich-text-fragment.v1"); + assert_eq!(fragment["paragraphs"][0]["runs"][0]["text"], "hello"); + } } From 89a9808eb865c11b75258845e2978bc59b764260 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:03:46 -0700 Subject: [PATCH 47/54] Document the capability example workflow --- examples/extensions/all-capabilities/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/extensions/all-capabilities/README.md b/examples/extensions/all-capabilities/README.md index 015ebd37..412a0d23 100644 --- a/examples/extensions/all-capabilities/README.md +++ b/examples/extensions/all-capabilities/README.md @@ -28,5 +28,13 @@ next invocation. The filesystem action records its observations in `/data/capabilities.log`; `/extension` is read-only and `/document` is available only for document actions. +After approving a directory, run **Write to last directory grant** in a later +invocation. The request action saves the returned mount path in `/data`, and the +follow-up action writes `flowstate-extension-example.txt` through that preopen. + +**Run until cancelled** intentionally spins forever to demonstrate Flowstate's +Cancel control and Wasmtime epoch interruption. The selection, table-cell, +refresh, and block insertion actions mutate the open document. + The network action sends a plain HTTP request to `example.com:80` to demonstrate WASI sockets without requiring TLS support in this small example. From ef6856e9fab636f0cfaf22501ec61269a1a66c5d Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:05:21 -0700 Subject: [PATCH 48/54] Explain all-capabilities extension actions --- .../extensions/all-capabilities/README.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/examples/extensions/all-capabilities/README.md b/examples/extensions/all-capabilities/README.md index 412a0d23..ac35bb74 100644 --- a/examples/extensions/all-capabilities/README.md +++ b/examples/extensions/all-capabilities/README.md @@ -28,6 +28,26 @@ next invocation. The filesystem action records its observations in `/data/capabilities.log`; `/extension` is read-only and `/document` is available only for document actions. +## Buttons + +- **Inspect document and selection** reads both snapshot APIs, reports them in + the panel, and changes its own label for the current session. +- **Replace selection** and **Delete selection** demonstrate normalized, + generation-checked rich-text edits. +- **Insert styled blocks** appends a styled paragraph, equation, table, and SVG + image asset in one undoable batch. +- **Replace selected table cell** exercises nested table editing. +- **Refresh from disk** exercises reload and Flowstate's dirty-document prompt. +- **Exercise scoped filesystems** reads `/extension`, writes `/data`, and probes + the saved document directory. +- **Fetch example.com** performs an outbound HTTP request over WASI sockets. +- **Request directory access** records a deferred read/write grant; **Write to + last directory grant** exercises it on a later invocation. +- **Exercise clock, random, and output** uses WASI time and randomness and emits + captured stdout and stderr. +- **Run until cancelled** intentionally loops so the Cancel button and Wasmtime + epoch interruption can be tested. + After approving a directory, run **Write to last directory grant** in a later invocation. The request action saves the returned mount path in `/data`, and the follow-up action writes `flowstate-extension-example.txt` through that preopen. From 19e7d4e81bd39ea7b65a11d16279582a817baf16 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:13:52 -0700 Subject: [PATCH 49/54] Link base WASI with extension HTTP support --- crates/flowstate-extension/src/runtime.rs | 5 +- .../tests/fixtures/wasi_environment.wat | 56 +++++++++++++++++++ .../flowstate-extension/tests/runtime_e2e.rs | 22 ++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 crates/flowstate-extension/tests/fixtures/wasi_environment.wat diff --git a/crates/flowstate-extension/src/runtime.rs b/crates/flowstate-extension/src/runtime.rs index 03863f5e..30966d51 100644 --- a/crates/flowstate-extension/src/runtime.rs +++ b/crates/flowstate-extension/src/runtime.rs @@ -136,10 +136,9 @@ impl Runtime { } }); let mut linker = Linker::new(&self.engine); + wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?; if self.config.allow_network { - wasmtime_wasi_http::add_to_linker_sync(&mut linker)?; - } else { - wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?; + wasmtime_wasi_http::add_only_http_to_linker_sync(&mut linker)?; } flowstate::extension::host::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |state| state)?; let instance = Extension::instantiate(&mut store, &component, &linker)?; diff --git a/crates/flowstate-extension/tests/fixtures/wasi_environment.wat b/crates/flowstate-extension/tests/fixtures/wasi_environment.wat new file mode 100644 index 00000000..021d7d63 --- /dev/null +++ b/crates/flowstate-extension/tests/fixtures/wasi_environment.wat @@ -0,0 +1,56 @@ +(component + (type $environment-type (instance + (type $get-environment-type (func (result (list (tuple string string))))) + (type $get-arguments-type (func (result (list string)))) + (type $initial-cwd-type (func (result (option string)))) + (export "get-environment" (func (type $get-environment-type))) + (export "get-arguments" (func (type $get-arguments-type))) + (export "initial-cwd" (func (type $initial-cwd-type))) + )) + (import "wasi:cli/environment@0.2.9" (instance $environment (type $environment-type))) + + (type $host-type (instance + (type $status-type (func (param "message" string))) + (export "set-status" (func (type $status-type))) + )) + (import "flowstate:extension/host@1.0.0" (instance $host (type $host-type))) + (alias export $host "set-status" (func $status)) + + (core module $memory-module + (memory (export "memory") 1) + (data (i32.const 32) "called") + ) + (core instance $memory-instance (instantiate $memory-module)) + (alias core export $memory-instance "memory" (core memory $memory)) + (core func $status-core + (canon lower (func $status) (memory $memory) string-encoding=utf8) + ) + (core instance $imports + (export "memory" (memory $memory)) + (export "set-status" (func $status-core)) + ) + + (core module $guest + (import "imports" "memory" (memory 1)) + (import "imports" "set-status" (func $status (param i32 i32))) + (func (export "cabi_realloc") (param i32 i32 i32 i32) (result i32) i32.const 1024) + (func (export "run") (param i32 i32) (result i32) + i32.const 32 + i32.const 6 + call $status + i32.const 2048 + i32.const 0 + i32.store + i32.const 2048 + ) + ) + (core instance $guest-instance (instantiate $guest (with "imports" (instance $imports)))) + (alias core export $guest-instance "cabi_realloc" (core func $realloc)) + (alias core export $guest-instance "run" (core func $run-core)) + + (type $run-type (func (param "action-id" string) (result (result (error string))))) + (func $run (type $run-type) + (canon lift (core func $run-core) (memory $memory) (realloc $realloc) string-encoding=utf8) + ) + (export "run" (func $run)) +) diff --git a/crates/flowstate-extension/tests/runtime_e2e.rs b/crates/flowstate-extension/tests/runtime_e2e.rs index 60368c30..faf24d8f 100644 --- a/crates/flowstate-extension/tests/runtime_e2e.rs +++ b/crates/flowstate-extension/tests/runtime_e2e.rs @@ -46,6 +46,28 @@ mod tests { assert_eq!(*status.lock().unwrap(), ["called"]); } + #[test] + fn network_enabled_runtime_also_links_base_wasi() { + let directory = tempfile::tempdir().unwrap(); + let component = directory.path().join("wasi-environment.wat"); + std::fs::write(&component, include_str!("fixtures/wasi_environment.wat")).unwrap(); + let invocation = Invocation { + component, + extension_root: directory.path().to_path_buf(), + data_root: directory.path().join("data"), + document_root: None, + action_id: "run".to_owned(), + directory_grants: Vec::new(), + }; + let runtime = Runtime::new(RuntimeConfig { allow_network: true, ..RuntimeConfig::default() }).unwrap(); + let cancellation = runtime.cancellation_handle(); + let status = Arc::new(Mutex::new(Vec::new())); + + runtime.invoke("com.example.wasi", &invocation, Host { status: Arc::clone(&status), signal: None }, &cancellation).unwrap(); + + assert_eq!(*status.lock().unwrap(), ["called"]); + } + #[test] fn cancels_a_running_component() { let directory = tempfile::tempdir().unwrap(); From 209de19916f3d247827733974e5afc0d6e55ccb5 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:21:44 -0700 Subject: [PATCH 50/54] fix(extensions): allow guest DNS lookup --- crates/flowstate-extension/src/runtime.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/flowstate-extension/src/runtime.rs b/crates/flowstate-extension/src/runtime.rs index 30966d51..9da00c84 100644 --- a/crates/flowstate-extension/src/runtime.rs +++ b/crates/flowstate-extension/src/runtime.rs @@ -190,7 +190,9 @@ fn build_wasi( }; builder.preopened_dir(&grant.host_path, guest_path, dir_perms, file_perms)?; } - if config.allow_network { builder.inherit_network(); } + if config.allow_network { + builder.inherit_network().allow_ip_name_lookup(true); + } Ok(builder.build()) } From 490ed6f97a3fd8e69852e086f849bc23f34b83e1 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:22:45 -0700 Subject: [PATCH 51/54] Enable precise extension JSON numbers --- crates/gpui-flowtext/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpui-flowtext/Cargo.toml b/crates/gpui-flowtext/Cargo.toml index 59b84aa0..75427e22 100644 --- a/crates/gpui-flowtext/Cargo.toml +++ b/crates/gpui-flowtext/Cargo.toml @@ -19,7 +19,7 @@ postcard = { version = "1.1.3", features = ["use-std"] } resvg = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["arbitrary_precision"] } tempfile = { workspace = true } typst = { workspace = true } typst-assets = { workspace = true, features = ["fonts"] } From dc2ab4d1708503a18532ea75a148b3ddfa759077 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:22:45 -0700 Subject: [PATCH 52/54] Decode asset IDs safely in extension edits --- .../src/rich_text/editor/extension_wire.rs | 149 +++++++++++++++++- 1 file changed, 148 insertions(+), 1 deletion(-) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs index 8c225b9d..4676d369 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_wire.rs @@ -67,7 +67,7 @@ pub struct ExtensionWireEditRequest { pub edits: Vec, } -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, serde::Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ExtensionWireEdit { ReplaceText { @@ -90,6 +90,97 @@ pub enum ExtensionWireEdit { }, } +impl<'de> serde::Deserialize<'de> for ExtensionWireEdit { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + + #[derive(serde::Deserialize)] + struct ReplaceText { + start: ExtensionWireOffset, + end: ExtensionWireOffset, + fragment: RichClipboardFragment, + } + #[derive(serde::Deserialize)] + struct SpliceBlocks { + start: usize, + end: usize, + blocks: Vec, + #[serde(default)] + assets: Vec, + } + #[derive(serde::Deserialize)] + struct ReplaceTableCell { + block_ix: usize, + row_ix: usize, + cell_ix: usize, + blocks: Vec, + } + + let mut value = serde_json::Value::deserialize(deserializer)?; + normalize_asset_ids(&mut value).map_err(D::Error::custom)?; + let kind = value + .as_object_mut() + .and_then(|object| object.remove("kind")) + .and_then(|kind| kind.as_str().map(str::to_owned)) + .ok_or_else(|| D::Error::custom("extension edit is missing a string kind"))?; + match kind.as_str() { + "replace_text" => { + let edit: ReplaceText = serde_json::from_value(value).map_err(D::Error::custom)?; + Ok(Self::ReplaceText { + start: edit.start, + end: edit.end, + fragment: edit.fragment, + }) + }, + "splice_blocks" => { + let edit: SpliceBlocks = serde_json::from_value(value).map_err(D::Error::custom)?; + Ok(Self::SpliceBlocks { + start: edit.start, + end: edit.end, + blocks: edit.blocks, + assets: edit.assets, + }) + }, + "replace_table_cell" => { + let edit: ReplaceTableCell = serde_json::from_value(value).map_err(D::Error::custom)?; + Ok(Self::ReplaceTableCell { + block_ix: edit.block_ix, + row_ix: edit.row_ix, + cell_ix: edit.cell_ix, + blocks: edit.blocks, + }) + }, + _ => Err(D::Error::custom(format!("unknown extension edit kind `{kind}`"))), + } + } +} + +fn normalize_asset_ids(value: &mut serde_json::Value) -> Result<(), String> { + match value { + serde_json::Value::Object(object) => { + let is_asset = object.contains_key("mime_type") && object.contains_key("bytes"); + for (key, value) in object { + if (key == "asset_id" || (key == "id" && is_asset)) && value.is_string() { + let text = value.as_str().expect("checked string"); + *value = serde_json::Value::Number(text.parse().map_err(|_| format!("invalid asset ID `{text}`"))?); + } else { + normalize_asset_ids(value)?; + } + } + }, + serde_json::Value::Array(values) => { + for value in values { + normalize_asset_ids(value)?; + } + }, + _ => {}, + } + Ok(()) +} + #[derive(Clone, Copy, Debug, serde::Serialize)] pub struct ExtensionWireEditResponse { pub generation: u64, @@ -249,4 +340,60 @@ mod extension_wire_tests { if range == (1..3) && matches!(&blocks[0], InputBlock::Equation(equation) if equation.source == "x") )); } + + #[test] + fn block_splice_request_accepts_string_u128_asset_ids() { + let asset_id = AssetId(u128::from(u64::MAX) + 1); + let request = ExtensionWireEditRequest { + expected_generation: 7, + edits: vec![ExtensionWireEdit::SpliceBlocks { + start: 0, + end: 0, + blocks: Vec::new(), + assets: vec![InputAsset { + id: asset_id, + mime_type: "image/png".to_owned(), + original_name: Some("pixel.png".to_owned()), + content_hash: 42, + bytes: vec![137, 80, 78, 71], + }], + }], + }; + + let json = serde_json::to_string(&request) + .unwrap() + .replace(&asset_id.0.to_string(), &format!("\"{}\"", asset_id.0)); + let decoded: ExtensionWireEditRequest = serde_json::from_str(&json).unwrap(); + + assert!(matches!( + &decoded.edits[0], + ExtensionWireEdit::SpliceBlocks { assets, .. } if assets[0].id == asset_id + )); + } + + #[test] + fn example_image_splice_accepts_legacy_numeric_asset_ids() { + let json = r#"{ + "expected_generation": 7, + "edits": [{ + "kind": "splice_blocks", "start": 0, "end": 0, + "blocks": [{"Image": { + "asset_id": 42, "alt_text": "example", "caption": null, + "sizing": "Intrinsic", "alignment": "Center" + }}], + "assets": [{ + "id": 42, "mime_type": "image/svg+xml", "original_name": "wasm.svg", + "content_hash": 42, "bytes": [60, 115, 118, 103, 47, 62] + }] + }] + }"#; + + let decoded: ExtensionWireEditRequest = serde_json::from_str(json).unwrap(); + assert!(matches!( + &decoded.edits[0], + ExtensionWireEdit::SpliceBlocks { blocks, assets, .. } + if assets[0].id == AssetId(42) + && matches!(&blocks[0], InputBlock::Image(image) if image.asset_id == AssetId(42)) + )); + } } From bf92699520dab621b8fab66087e8c618ec7497fe Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:22:45 -0700 Subject: [PATCH 53/54] Emit string asset IDs in extension example --- examples/extensions/all-capabilities/src/actions/blocks.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/extensions/all-capabilities/src/actions/blocks.rs b/examples/extensions/all-capabilities/src/actions/blocks.rs index eb5632a7..f79bbf42 100644 --- a/examples/extensions/all-capabilities/src/actions/blocks.rs +++ b/examples/extensions/all-capabilities/src/actions/blocks.rs @@ -15,6 +15,7 @@ pub fn insert() -> Result<(), String> { let mut random = [0_u8; 8]; getrandom::fill(&mut random).map_err(|error| error.to_string())?; let asset_id = u64::from_le_bytes(random); + let asset_wire_id = asset_id.to_string(); let svg: &[u8] = br#" Wasm"#; let paragraph = json!({ "Paragraph": { @@ -33,13 +34,13 @@ pub fn insert() -> Result<(), String> { ]}], "column_widths": ["Auto", "Auto"], "style": { "header_row": false } }}); let image = json!({ "Image": { - "asset_id": asset_id, "alt_text": "Generated by the example extension", + "asset_id": asset_wire_id, "alt_text": "Generated by the example extension", "caption": null, "sizing": "Intrinsic", "alignment": "Center" }}); let request = json!({ "expected_generation": generation, "edits": [{ "kind": "splice_blocks", "start": end, "end": end, "blocks": [paragraph, equation, table, image], "assets": [{ - "id": asset_id, "mime_type": "image/svg+xml", "original_name": "wasm.svg", + "id": asset_wire_id, "mime_type": "image/svg+xml", "original_name": "wasm.svg", "content_hash": asset_id, "bytes": svg }] }]}); From 61e9f62fb0bbb6ade475a5936810c3dc3a390645 Mon Sep 17 00:00:00 2001 From: Chinmay Khaladkar Date: Sun, 12 Jul 2026 23:31:28 -0700 Subject: [PATCH 54/54] Derive extension asset hashes from bytes --- .../src/rich_text/editor/extension_api.rs | 35 ++++++++++++++++++- .../all-capabilities/src/actions/blocks.rs | 2 +- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs index 0b6c4e18..19dba535 100644 --- a/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs +++ b/crates/gpui-flowtext/src/rich_text/editor/extension_api.rs @@ -193,13 +193,15 @@ fn clamped_extension_offset(document: &Document, offset: DocumentOffset) -> Docu fn insert_extension_assets(store: &mut AssetStore, assets: &[InputAsset]) { for asset in assets { + let mut hasher = DefaultHasher::new(); + asset.bytes.hash(&mut hasher); store.assets.insert( asset.id, AssetRecord { id: asset.id, mime_type: asset.mime_type.clone().into(), original_name: asset.original_name.clone().map(Into::into), - content_hash: asset.content_hash, + content_hash: hasher.finish(), bytes: Arc::new(asset.bytes.clone()), }, ); @@ -350,4 +352,35 @@ mod extension_api_tests { assert!(document_bytes(&document).is_ok()); } + #[test] + fn block_splice_normalizes_untrusted_asset_hash() { + let mut document = text_document("before"); + let asset_id = AssetId(42); + let bytes = b"".to_vec(); + let edit = ExtensionDocumentEdit::SpliceBlocks { + range: 1..1, + blocks: vec![InputBlock::Image(InputImageBlock { + asset_id, + alt_text: "extension image".to_owned(), + caption: None, + sizing: InputImageSizing::Intrinsic, + alignment: InputBlockAlignment::Center, + })], + assets: vec![InputAsset { + id: asset_id, + mime_type: "image/svg+xml".to_owned(), + original_name: Some("extension.svg".to_owned()), + content_hash: 42, + bytes: bytes.clone(), + }], + }; + + apply_extension_edit(&mut document, &edit).unwrap(); + + let mut hasher = DefaultHasher::new(); + bytes.hash(&mut hasher); + assert_eq!(document.assets.assets[&asset_id].content_hash, hasher.finish()); + assert!(document_bytes(&document).is_ok()); + } + } diff --git a/examples/extensions/all-capabilities/src/actions/blocks.rs b/examples/extensions/all-capabilities/src/actions/blocks.rs index f79bbf42..90016d4d 100644 --- a/examples/extensions/all-capabilities/src/actions/blocks.rs +++ b/examples/extensions/all-capabilities/src/actions/blocks.rs @@ -41,7 +41,7 @@ pub fn insert() -> Result<(), String> { "kind": "splice_blocks", "start": end, "end": end, "blocks": [paragraph, equation, table, image], "assets": [{ "id": asset_wire_id, "mime_type": "image/svg+xml", "original_name": "wasm.svg", - "content_hash": asset_id, "bytes": svg + "content_hash": 0, "bytes": svg }] }]}); host::apply_edits(&request.to_string()).map_err(host_error)?;