Compare commits

...

5 Commits

Author SHA1 Message Date
Edward Frazer
80628db404 fix(app-server): isolate staged upload forwarding 2026-05-07 13:38:31 -07:00
Edward Frazer
52d5ccb241 fix(app-server): accept create attrs on staged uploads 2026-05-07 12:13:59 -07:00
Edward Frazer
d140c3a400 fix(app-server): scope staged uploads per connection 2026-05-07 10:07:31 -07:00
Edward Frazer
4c6c974a4b refactor(app-server): reuse russh-sftp for staged uploads 2026-05-07 09:48:58 -07:00
Edward Frazer
381c171490 feat(app-server): stream staged uploads over websocket sftp 2026-05-07 09:48:58 -07:00
30 changed files with 959 additions and 209 deletions

16
MODULE.bazel.lock generated

File diff suppressed because one or more lines are too long

273
codex-rs/Cargo.lock generated
View File

@@ -18,7 +18,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"bytes",
"futures-core",
"futures-sink",
@@ -39,7 +39,7 @@ dependencies = [
"actix-rt",
"actix-service",
"actix-utils",
"bitflags 2.10.0",
"bitflags 2.11.1",
"bytes",
"bytestring",
"derive_more 2.1.1",
@@ -298,7 +298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.10.0",
"bitflags 2.11.1",
"cfg-if",
"libc",
]
@@ -1180,12 +1180,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.21.7"
@@ -1250,7 +1244,7 @@ version = "0.72.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"cexpr",
"clang-sys",
"itertools 0.13.0",
@@ -1287,9 +1281,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [
"serde_core",
]
@@ -1614,9 +1608,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.43"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -1904,6 +1898,7 @@ dependencies = [
"pretty_assertions",
"reqwest",
"rmcp",
"russh-sftp",
"serde",
"serde_json",
"serial_test",
@@ -4399,7 +4394,7 @@ name = "crossterm"
version = "0.28.1"
source = "git+https://github.com/nornagon/crossterm?rev=87db8bfa6dc99427fd3b071681b07fc31c6ce995#87db8bfa6dc99427fd3b071681b07fc31c6ce995"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"crossterm_winapi",
"futures-core",
"mio",
@@ -4425,18 +4420,6 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -5045,7 +5028,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"objc2",
]
@@ -5150,20 +5133,6 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
@@ -5197,26 +5166,6 @@ dependencies = [
"serde",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "ena"
version = "0.14.3"
@@ -5470,16 +5419,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
@@ -6131,7 +6070,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "441a300bc3645a1f45cba495b9175f90f47256ce43f2ee161da0031e3ac77c92"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"bstr",
"gix-path",
"libc",
@@ -6277,7 +6216,7 @@ version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b03e6cd88cc0dc1eafa1fddac0fb719e4e74b6ea58dd016e71125fde4a326bee"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"bstr",
"gix-features",
"gix-path",
@@ -6325,7 +6264,7 @@ version = "0.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bae54ab14e4e74d5dda60b82ea7afad7c8eb3be68283d6d5f29bd2e6d47fff7"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"bstr",
"filetime",
"fnv",
@@ -6390,7 +6329,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea064c7595eea08fdd01c70748af747d9acc40f727b61f4c8a2145a5c5fc28c"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"gix-commitgraph",
"gix-date",
"gix-hash",
@@ -6488,7 +6427,7 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f89611f13544ca5ebeb68a502673814ef57200df60c24a61c2ce7b96f612f08b"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"bstr",
"gix-attributes",
"gix-config-value",
@@ -6571,7 +6510,7 @@ version = "0.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c08f1ec5d1e6a524f8ba291c41f0ccaef64e48ed0e8cf790b3461cae45f6d3d"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"bstr",
"gix-commitgraph",
"gix-date",
@@ -6605,7 +6544,7 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf82ae037de9c62850ce67beaa92ec8e3e17785ea307cdde7618edc215603b4f"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"gix-path",
"libc",
"windows-sys 0.61.2",
@@ -6703,7 +6642,7 @@ version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "963dc2afcdb611092aa587c3f9365e749ac0a0892ff27662dbc75f26c953fbec"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"gix-commitgraph",
"gix-date",
"gix-hash",
@@ -6806,7 +6745,7 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"futures-channel",
"futures-core",
"futures-executor",
@@ -6874,17 +6813,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "gzip-header"
version = "1.0.0"
@@ -7688,7 +7616,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"inotify-sys",
"libc",
]
@@ -8119,7 +8047,7 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"libc",
"redox_syscall 0.7.0",
]
@@ -8192,7 +8120,7 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"libc",
]
@@ -8582,7 +8510,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"jni-sys",
"log",
"ndk-sys",
@@ -8626,7 +8554,7 @@ version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
@@ -8638,7 +8566,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
@@ -8651,7 +8579,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
@@ -8694,7 +8622,7 @@ version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"fsevent-sys",
"inotify",
"kqueue",
@@ -8712,7 +8640,7 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
]
[[package]]
@@ -8897,7 +8825,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64 0.21.7",
"base64 0.22.1",
"chrono",
"getrandom 0.2.17",
"http 1.4.0",
@@ -8926,7 +8854,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"objc2",
"objc2-core-graphics",
"objc2-foundation",
@@ -8938,7 +8866,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"objc2",
"objc2-foundation",
]
@@ -8959,7 +8887,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"dispatch2",
"objc2",
]
@@ -8970,7 +8898,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"dispatch2",
"objc2",
"objc2-core-foundation",
@@ -9003,7 +8931,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -9021,7 +8949,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
@@ -9034,7 +8962,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
]
@@ -9045,7 +8973,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
@@ -9057,7 +8985,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"block2",
"objc2",
"objc2-cloud-kit",
@@ -9145,7 +9073,7 @@ version = "6.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"libc",
"once_cell",
"onig_sys",
@@ -9173,7 +9101,7 @@ version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"cfg-if",
"foreign-types",
"libc",
@@ -9365,7 +9293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9384,18 +9312,6 @@ dependencies = [
"supports-color 3.0.2",
]
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -9675,7 +9591,7 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"crc32fast",
"fdeflate",
"flate2",
@@ -9825,15 +9741,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro-crate"
version = "3.4.0"
@@ -9903,7 +9810,7 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"num-traits",
"rand 0.9.3",
"rand_chacha 0.9.0",
@@ -10047,7 +9954,7 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"getopts",
"memchr",
"pulldown-cmark-escape",
@@ -10227,7 +10134,7 @@ checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd"
dependencies = [
"ahash",
"base64 0.22.1",
"bitflags 2.10.0",
"bitflags 2.11.1",
"chrono",
"const_format",
"csv",
@@ -10558,7 +10465,7 @@ name = "ratatui"
version = "0.29.0"
source = "git+https://github.com/nornagon/ratatui?rev=9b2ad1298408c45918ee9f8241a6f95498cdbed2#9b2ad1298408c45918ee9f8241a6f95498cdbed2"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"cassowary",
"compact_str",
"crossterm",
@@ -10622,7 +10529,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
]
[[package]]
@@ -10631,7 +10538,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
]
[[package]]
@@ -10784,16 +10691,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -10888,6 +10785,24 @@ name = "runfiles"
version = "0.1.0"
source = "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81"
[[package]]
name = "russh-sftp"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09daa0ebcf53fb18d7b16167586a68b5bf2cfa3eaad49e661a19302552a2b879"
dependencies = [
"bitflags 2.11.1",
"bytes",
"chrono",
"dashmap",
"log",
"serde",
"serde_bytes",
"thiserror 2.0.18",
"tokio",
"tokio-util",
]
[[package]]
name = "rust-embed"
version = "8.11.0"
@@ -10974,7 +10889,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys 0.4.15",
@@ -10987,7 +10902,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys 0.12.1",
@@ -11056,7 +10971,7 @@ version = "14.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"cfg-if",
"clipboard-win",
"fd-lock",
@@ -11253,20 +11168,6 @@ version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "seccompiler"
version = "0.5.0"
@@ -11310,7 +11211,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
@@ -11323,7 +11224,7 @@ version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -11458,7 +11359,7 @@ version = "0.46.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1dd47df349a80025819f3d25c3d2f751df705d49c65a4cdc0f130f700972a48"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"sentry-backtrace",
"sentry-core",
"tracing-core",
@@ -11492,6 +11393,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -11988,7 +11899,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64 0.22.1",
"bitflags 2.10.0",
"bitflags 2.11.1",
"byteorder",
"bytes",
"chrono",
@@ -12033,7 +11944,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64 0.22.1",
"bitflags 2.10.0",
"bitflags 2.11.1",
"byteorder",
"chrono",
"crc",
@@ -12424,7 +12335,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -13069,7 +12980,7 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"bytes",
"futures-util",
"http 1.4.0",
@@ -13583,7 +13494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d97bcac5cdc5a195a4813f1855a6bc658f240452aac36caa12fd6c6f16026ab1"
dependencies = [
"bindgen",
"bitflags 2.10.0",
"bitflags 2.11.1",
"fslock",
"gzip-header",
"home",
@@ -13784,7 +13695,7 @@ version = "0.31.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"rustix 1.1.4",
"wayland-backend",
"wayland-scanner",
@@ -13796,7 +13707,7 @@ version = "0.32.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"wayland-backend",
"wayland-client",
"wayland-scanner",
@@ -13808,7 +13719,7 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3"
dependencies = [
"bitflags 2.10.0",
"bitflags 2.11.1",
"wayland-backend",
"wayland-client",
"wayland-protocols",
@@ -14009,7 +13920,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@@ -334,6 +334,7 @@ regex-lite = "0.1.8"
reqwest = { version = "0.12", features = ["cookies"] }
rmcp = { version = "0.15.0", default-features = false }
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
russh-sftp = "2.1.2"
rustls = { version = "0.23", default-features = false, features = [
"ring",
"std",

View File

@@ -969,6 +969,19 @@
],
"type": "object"
},
"FsCreateUploadParams": {
"description": "Allocate a Codex-managed host path for a staged upload.",
"properties": {
"fileName": {
"description": "User-visible file name. App-server stores only the final path component.",
"type": "string"
}
},
"required": [
"fileName"
],
"type": "object"
},
"FsGetMetadataParams": {
"description": "Request metadata for an absolute path.",
"properties": {
@@ -5148,6 +5161,30 @@
"title": "Fs/writeFileRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"fs/createUpload"
],
"title": "Fs/createUploadRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsCreateUploadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/createUploadRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -6181,4 +6218,4 @@
}
],
"title": "ClientRequest"
}
}

View File

@@ -954,6 +954,30 @@
"title": "Fs/writeFileRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"fs/createUpload"
],
"title": "Fs/createUploadRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/FsCreateUploadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/createUploadRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -8687,6 +8711,40 @@
"title": "FsCreateDirectoryResponse",
"type": "object"
},
"FsCreateUploadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Allocate a Codex-managed host path for a staged upload.",
"properties": {
"fileName": {
"description": "User-visible file name. App-server stores only the final path component.",
"type": "string"
}
},
"required": [
"fileName"
],
"title": "FsCreateUploadParams",
"type": "object"
},
"FsCreateUploadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Codex-managed host path created by `fs/createUpload`.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
}
],
"description": "Absolute path where the client should stream staged upload bytes."
}
},
"required": [
"path"
],
"title": "FsCreateUploadResponse",
"type": "object"
},
"FsGetMetadataParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Request metadata for an absolute path.",
@@ -18374,4 +18432,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}

View File

@@ -1713,6 +1713,30 @@
"title": "Fs/writeFileRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"fs/createUpload"
],
"title": "Fs/createUploadRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsCreateUploadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/createUploadRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -5143,6 +5167,40 @@
"title": "FsCreateDirectoryResponse",
"type": "object"
},
"FsCreateUploadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Allocate a Codex-managed host path for a staged upload.",
"properties": {
"fileName": {
"description": "User-visible file name. App-server stores only the final path component.",
"type": "string"
}
},
"required": [
"fileName"
],
"title": "FsCreateUploadParams",
"type": "object"
},
"FsCreateUploadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Codex-managed host path created by `fs/createUpload`.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Absolute path where the client should stream staged upload bytes."
}
},
"required": [
"path"
],
"title": "FsCreateUploadResponse",
"type": "object"
},
"FsGetMetadataParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Request metadata for an absolute path.",
@@ -16259,4 +16317,4 @@
},
"title": "CodexAppServerProtocolV2",
"type": "object"
}
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Allocate a Codex-managed host path for a staged upload.",
"properties": {
"fileName": {
"description": "User-visible file name. App-server stores only the final path component.",
"type": "string"
}
},
"required": [
"fileName"
],
"title": "FsCreateUploadParams",
"type": "object"
}

View File

@@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"description": "Codex-managed host path created by `fs/createUpload`.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Absolute path where the client should stream staged upload bytes."
}
},
"required": [
"path"
],
"title": "FsCreateUploadResponse",
"type": "object"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Allocate a Codex-managed host path for a staged upload.
*/
export type FsCreateUploadParams = {
/**
* User-visible file name. App-server stores only the final path component.
*/
fileName: string, };

View File

@@ -0,0 +1,13 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
/**
* Codex-managed host path created by `fs/createUpload`.
*/
export type FsCreateUploadResponse = {
/**
* Absolute path where the client should stream staged upload bytes.
*/
path: AbsolutePathBuf, };

View File

@@ -116,6 +116,8 @@ export type { FsCopyParams } from "./FsCopyParams";
export type { FsCopyResponse } from "./FsCopyResponse";
export type { FsCreateDirectoryParams } from "./FsCreateDirectoryParams";
export type { FsCreateDirectoryResponse } from "./FsCreateDirectoryResponse";
export type { FsCreateUploadParams } from "./FsCreateUploadParams";
export type { FsCreateUploadResponse } from "./FsCreateUploadResponse";
export type { FsGetMetadataParams } from "./FsGetMetadataParams";
export type { FsGetMetadataResponse } from "./FsGetMetadataResponse";
export type { FsReadDirectoryEntry } from "./FsReadDirectoryEntry";

View File

@@ -664,6 +664,11 @@ client_request_definitions! {
serialization: None,
response: v2::FsWriteFileResponse,
},
FsCreateUpload => "fs/createUpload" {
params: v2::FsCreateUploadParams,
serialization: None,
response: v2::FsCreateUploadResponse,
},
FsCreateDirectory => "fs/createDirectory" {
params: v2::FsCreateDirectoryParams,
serialization: None,

View File

@@ -39,6 +39,24 @@ pub struct FsWriteFileParams {
#[ts(export_to = "v2/")]
pub struct FsWriteFileResponse {}
/// Allocate a Codex-managed host path for a staged upload.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsCreateUploadParams {
/// User-visible file name. App-server stores only the final path component.
pub file_name: String,
}
/// Codex-managed host path created by `fs/createUpload`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsCreateUploadResponse {
/// Absolute path where the client should stream staged upload bytes.
pub path: AbsolutePathBuf,
}
/// Create a directory on the host filesystem.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]

View File

@@ -664,6 +664,25 @@ fn fs_read_file_params_round_trip() {
assert_eq!(decoded, params);
}
#[test]
fn fs_create_upload_params_round_trip() {
let params = FsCreateUploadParams {
file_name: "example.bin".to_string(),
};
let value = serde_json::to_value(&params).expect("serialize fs/createUpload params");
assert_eq!(
value,
json!({
"fileName": "example.bin",
})
);
let decoded = serde_json::from_value::<FsCreateUploadParams>(value)
.expect("deserialize fs/createUpload params");
assert_eq!(decoded, params);
}
#[test]
fn fs_create_directory_params_round_trip_with_default_recursive() {
let params = FsCreateDirectoryParams {

View File

@@ -152,6 +152,8 @@ pub enum TransportEvent {
connection_id: ConnectionId,
origin: ConnectionOrigin,
writer: mpsc::Sender<QueuedOutgoingMessage>,
binary_writer: Option<mpsc::Sender<Vec<u8>>>,
binary_reader: Option<mpsc::Receiver<Vec<u8>>>,
disconnect_sender: Option<CancellationToken>,
},
ConnectionClosed {
@@ -161,6 +163,10 @@ pub enum TransportEvent {
connection_id: ConnectionId,
message: JSONRPCMessage,
},
IncomingBinary {
connection_id: ConnectionId,
bytes: Vec<u8>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -163,6 +163,8 @@ impl ClientTracker {
connection_id,
origin: ConnectionOrigin::RemoteControl,
writer: writer_tx,
binary_writer: None,
binary_reader: None,
disconnect_sender: Some(disconnect_token.clone()),
})
.await?;

View File

@@ -34,6 +34,8 @@ pub async fn start_stdio_connection(
connection_id,
origin: ConnectionOrigin::Stdio,
writer: writer_tx,
binary_writer: None,
binary_reader: None,
disconnect_sender: None,
})
.await

View File

@@ -53,7 +53,7 @@ fn listen_unix_socket_accepts_relative_custom_path() {
}
#[tokio::test]
async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_messages_and_pings() {
async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_binary_messages_and_pings() {
let temp_dir = tempfile::TempDir::new().expect("temp dir");
let socket_path = test_socket_path(temp_dir.path());
let (transport_event_tx, mut transport_event_rx) =
@@ -79,8 +79,12 @@ async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_messages_a
.await
.expect("connection opened event should arrive")
.expect("connection opened event");
let connection_id = match opened {
TransportEvent::ConnectionOpened { connection_id, .. } => connection_id,
let (connection_id, mut binary_reader) = match opened {
TransportEvent::ConnectionOpened {
connection_id,
binary_reader: Some(binary_reader),
..
} => (connection_id, binary_reader),
_ => panic!("expected connection opened event"),
};
@@ -112,6 +116,16 @@ async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_messages_a
(connection_id, notification)
);
websocket
.send(WebSocketMessage::Binary(Bytes::from_static(b"sftp")))
.await
.expect("binary payload should send");
let incoming_binary = timeout(Duration::from_secs(1), binary_reader.recv())
.await
.expect("incoming binary payload should arrive")
.expect("incoming binary payload");
assert_eq!(incoming_binary, b"sftp".to_vec());
websocket
.send(WebSocketMessage::Ping(Bytes::from_static(b"check")))
.await

View File

@@ -182,12 +182,16 @@ pub(crate) async fn run_websocket_connection<M, SinkError, StreamError>(
let (writer_tx, writer_rx) =
mpsc::channel::<QueuedOutgoingMessage>(WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY);
let writer_tx_for_reader = writer_tx.clone();
let (binary_writer_tx, binary_writer_rx) = mpsc::channel::<Vec<u8>>(CHANNEL_CAPACITY);
let (binary_reader_tx, binary_reader_rx) = mpsc::channel::<Vec<u8>>(CHANNEL_CAPACITY);
let disconnect_token = CancellationToken::new();
if transport_event_tx
.send(TransportEvent::ConnectionOpened {
connection_id,
origin: ConnectionOrigin::WebSocket,
writer: writer_tx,
binary_writer: Some(binary_writer_tx.clone()),
binary_reader: Some(binary_reader_rx),
disconnect_sender: Some(disconnect_token.clone()),
})
.await
@@ -201,6 +205,7 @@ pub(crate) async fn run_websocket_connection<M, SinkError, StreamError>(
websocket_writer,
writer_rx,
writer_control_rx,
binary_writer_rx,
disconnect_token.clone(),
));
let mut inbound_task = tokio::spawn(run_websocket_inbound_loop(
@@ -208,6 +213,7 @@ pub(crate) async fn run_websocket_connection<M, SinkError, StreamError>(
transport_event_tx.clone(),
writer_tx_for_reader,
writer_control_tx,
binary_reader_tx,
connection_id,
disconnect_token.clone(),
));
@@ -230,7 +236,7 @@ pub(crate) async fn run_websocket_connection<M, SinkError, StreamError>(
pub(crate) enum IncomingWebSocketMessage {
Text(String),
Binary,
Binary(Vec<u8>),
Ping(Bytes),
Pong,
Close,
@@ -241,6 +247,7 @@ pub(crate) enum IncomingWebSocketMessage {
/// sends directly.
pub(crate) trait AppServerWebSocketMessage: Sized {
fn text(text: String) -> Self;
fn binary(payload: Vec<u8>) -> Self;
fn pong(payload: Bytes) -> Self;
fn into_incoming(self) -> Option<IncomingWebSocketMessage>;
}
@@ -250,6 +257,10 @@ impl AppServerWebSocketMessage for AxumWebSocketMessage {
Self::Text(text.into())
}
fn binary(payload: Vec<u8>) -> Self {
Self::Binary(payload.into())
}
fn pong(payload: Bytes) -> Self {
Self::Pong(payload)
}
@@ -257,7 +268,7 @@ impl AppServerWebSocketMessage for AxumWebSocketMessage {
fn into_incoming(self) -> Option<IncomingWebSocketMessage> {
Some(match self {
Self::Text(text) => IncomingWebSocketMessage::Text(text.to_string()),
Self::Binary(_) => IncomingWebSocketMessage::Binary,
Self::Binary(payload) => IncomingWebSocketMessage::Binary(payload.to_vec()),
Self::Ping(payload) => IncomingWebSocketMessage::Ping(payload),
Self::Pong(_) => IncomingWebSocketMessage::Pong,
Self::Close(_) => IncomingWebSocketMessage::Close,
@@ -270,6 +281,10 @@ impl AppServerWebSocketMessage for TungsteniteWebSocketMessage {
Self::Text(text.into())
}
fn binary(payload: Vec<u8>) -> Self {
Self::Binary(payload.into())
}
fn pong(payload: Bytes) -> Self {
Self::Pong(payload)
}
@@ -277,7 +292,7 @@ impl AppServerWebSocketMessage for TungsteniteWebSocketMessage {
fn into_incoming(self) -> Option<IncomingWebSocketMessage> {
Some(match self {
Self::Text(text) => IncomingWebSocketMessage::Text(text.to_string()),
Self::Binary(_) => IncomingWebSocketMessage::Binary,
Self::Binary(payload) => IncomingWebSocketMessage::Binary(payload.to_vec()),
Self::Ping(payload) => IncomingWebSocketMessage::Ping(payload),
Self::Pong(_) => IncomingWebSocketMessage::Pong,
Self::Close(_) => IncomingWebSocketMessage::Close,
@@ -290,6 +305,7 @@ async fn run_websocket_outbound_loop<M, SinkError>(
websocket_writer: impl futures::sink::Sink<M, Error = SinkError> + Send + 'static,
mut writer_rx: mpsc::Receiver<QueuedOutgoingMessage>,
mut writer_control_rx: mpsc::Receiver<M>,
mut binary_writer_rx: mpsc::Receiver<Vec<u8>>,
disconnect_token: CancellationToken,
) where
M: AppServerWebSocketMessage + Send + 'static,
@@ -309,6 +325,14 @@ async fn run_websocket_outbound_loop<M, SinkError>(
break;
}
}
payload = binary_writer_rx.recv() => {
let Some(payload) = payload else {
break;
};
if websocket_writer.send(M::binary(payload)).await.is_err() {
break;
}
}
queued_message = writer_rx.recv() => {
let Some(queued_message) = queued_message else {
break;
@@ -332,6 +356,7 @@ async fn run_websocket_inbound_loop<M, StreamError>(
transport_event_tx: mpsc::Sender<TransportEvent>,
writer_tx_for_reader: mpsc::Sender<QueuedOutgoingMessage>,
writer_control_tx: mpsc::Sender<M>,
binary_reader_tx: mpsc::Sender<Vec<u8>>,
connection_id: ConnectionId,
disconnect_token: CancellationToken,
) where
@@ -371,8 +396,10 @@ async fn run_websocket_inbound_loop<M, StreamError>(
}
Some(IncomingWebSocketMessage::Pong) => {}
Some(IncomingWebSocketMessage::Close) => break,
Some(IncomingWebSocketMessage::Binary) => {
warn!("dropping unsupported binary websocket message");
Some(IncomingWebSocketMessage::Binary(bytes)) => {
if binary_reader_tx.send(bytes).await.is_err() {
break;
}
}
None => {}
},

View File

@@ -89,6 +89,7 @@ tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] }
url = { workspace = true }
uuid = { workspace = true, features = ["serde", "v7"] }
russh-sftp = { workspace = true }
[dev-dependencies]
app_test_support = { workspace = true }

View File

@@ -189,6 +189,7 @@ Example with notification opt-out:
- `process/exited` — experimental; notification emitted when a `process/spawn` session exits.
- `fs/readFile` — read an absolute file path and return `{ dataBase64 }`.
- `fs/writeFile` — write an absolute file path from base64-encoded `{ dataBase64 }`; returns `{}`.
- `fs/createUpload` — allocate a Codex-managed host path for a staged upload and return the absolute host `{ path }`.
- `fs/createDirectory` — create an absolute directory path; `recursive` defaults to `true`.
- `fs/getMetadata` — return metadata for an absolute path: `isDirectory`, `isFile`, `isSymlink`, `createdAtMs`, and `modifiedAtMs`.
- `fs/readDirectory` — list direct child entries for an absolute directory path; each entry contains `fileName`, `isDirectory`, and `isFile`, and `fileName` is just the child name, not a path.
@@ -1101,20 +1102,26 @@ All filesystem paths in this section must be absolute.
"dataBase64": "aGVsbG8="
} }
{ "id": 41, "result": {} }
{ "method": "fs/getMetadata", "id": 42, "params": {
"path": "/tmp/example/nested/note.txt"
{ "method": "fs/createUpload", "id": 42, "params": {
"fileName": "notes.txt"
} }
{ "id": 42, "result": {
"path": "/Users/me/.codex/uploads/019a7f95-9d35-75e2-9d0e-82bd8cf1af58/notes.txt"
} }
{ "method": "fs/getMetadata", "id": 43, "params": {
"path": "/tmp/example/nested/note.txt"
} }
{ "id": 43, "result": {
"isDirectory": false,
"isFile": true,
"isSymlink": false,
"createdAtMs": 1730910000000,
"modifiedAtMs": 1730910000000
} }
{ "method": "fs/readFile", "id": 43, "params": {
{ "method": "fs/readFile", "id": 44, "params": {
"path": "/tmp/example/nested/note.txt"
} }
{ "id": 43, "result": {
{ "id": 44, "result": {
"dataBase64": "aGVsbG8="
} }
```
@@ -1123,6 +1130,8 @@ All filesystem paths in this section must be absolute.
- `fs/createDirectory` defaults `recursive` to `true` when omitted.
- `fs/remove` defaults both `recursive` and `force` to `true` when omitted.
- `fs/readFile` always returns base64 bytes via `dataBase64`, and `fs/writeFile` always expects base64 bytes in `dataBase64`.
- `fs/createUpload` accepts a client-visible `fileName`, stores only its final path component, and chooses the destination under `$CODEX_HOME/uploads/<uuid>/`.
- Direct websocket transports keep JSON-RPC on text frames and reserve binary frames for the staged-upload SFTP lane. The SFTP lane is scoped to paths allocated by `fs/createUpload`.
- `fs/copy` handles both file copies and directory-tree copies; it requires `recursive: true` when `sourcePath` is a directory. Recursive copies traverse regular files, directories, and symlinks; other entry types are skipped.
### Example: Filesystem watch

View File

@@ -828,6 +828,8 @@ pub async fn run_main_with_transport_options(
connection_id,
origin,
writer,
binary_writer,
binary_reader,
disconnect_sender,
} => {
let outbound_initialized = Arc::new(AtomicBool::new(false));
@@ -853,6 +855,28 @@ pub async fn run_main_with_transport_options(
{
break;
}
if let (Some(binary_writer), Some(mut binary_reader)) =
(binary_writer.clone(), binary_reader)
{
let processor = Arc::clone(&processor);
tokio::spawn(async move {
while let Some(bytes) = binary_reader.recv().await {
if let Err(err) = processor
.process_upload_binary(
connection_id,
binary_writer.clone(),
bytes,
)
.await
{
warn!(
"failed to process upload binary payload: {}",
err.message
);
}
}
});
}
connections.insert(
connection_id,
ConnectionState::new(
@@ -860,6 +884,7 @@ pub async fn run_main_with_transport_options(
outbound_initialized,
outbound_experimental_api_enabled,
outbound_opted_out_notification_methods,
binary_writer,
),
);
}
@@ -962,6 +987,25 @@ pub async fn run_main_with_transport_options(
}
}
}
TransportEvent::IncomingBinary { connection_id, bytes } => {
let Some(connection_state) = connections.get(&connection_id) else {
warn!("dropping binary payload from unknown connection: {connection_id:?}");
continue;
};
let Some(binary_writer) = connection_state.binary_writer.clone() else {
warn!("dropping binary payload from connection without a binary lane: {connection_id:?}");
continue;
};
match processor
.process_upload_binary(connection_id, binary_writer, bytes)
.await
{
Ok(()) => {}
Err(err) => {
warn!("failed to process upload binary payload: {}", err.message);
}
}
}
}
}
changed = remote_control_status_rx.changed() => {

View File

@@ -438,6 +438,7 @@ impl MessageProcessor {
.local_environment()
.get_filesystem(),
fs_watch_manager,
config.codex_home.to_path_buf(),
);
let windows_sandbox_processor = WindowsSandboxRequestProcessor::new(
outgoing.clone(),
@@ -594,6 +595,17 @@ impl MessageProcessor {
tracing::info!("<- typed notification: {:?}", notification);
}
pub(crate) async fn process_upload_binary(
&self,
connection_id: ConnectionId,
binary_writer: tokio::sync::mpsc::Sender<Vec<u8>>,
bytes: Vec<u8>,
) -> Result<(), JSONRPCErrorError> {
self.fs_processor
.process_upload_sftp_bytes(connection_id, binary_writer, bytes)
.await
}
async fn run_request_with_context<F>(
outgoing: Arc<OutgoingMessageSender>,
request_context: RequestContext,
@@ -860,6 +872,11 @@ impl MessageProcessor {
.write_file(params)
.await
.map(|response| Some(response.into())),
ClientRequest::FsCreateUpload { params, .. } => self
.fs_processor
.create_upload(connection_id, params)
.await
.map(|response| Some(response.into())),
ClientRequest::FsCreateDirectory { params, .. } => self
.fs_processor
.create_directory(params)

View File

@@ -448,6 +448,7 @@ mod search;
mod thread_processor;
mod token_usage_replay;
mod turn_processor;
mod upload_sftp;
mod windows_sandbox_processor;
pub(crate) use account_processor::AccountRequestProcessor;

View File

@@ -2,12 +2,15 @@ use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::fs_watch::FsWatchManager;
use crate::outgoing_message::ConnectionId;
use crate::request_processors::upload_sftp::UploadSftpLane;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCopyResponse;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsCreateDirectoryResponse;
use codex_app_server_protocol::FsCreateUploadParams;
use codex_app_server_protocol::FsCreateUploadResponse;
use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsGetMetadataResponse;
use codex_app_server_protocol::FsReadDirectoryEntry;
@@ -28,28 +31,49 @@ use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::RemoveOptions;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
type UploadPaths = Arc<Mutex<HashSet<PathBuf>>>;
#[derive(Clone)]
pub(crate) struct FsRequestProcessor {
file_system: Arc<dyn ExecutorFileSystem>,
fs_watch_manager: FsWatchManager,
codex_home: PathBuf,
upload_paths_by_connection: Arc<Mutex<HashMap<ConnectionId, UploadPaths>>>,
upload_sftp_lanes: Arc<Mutex<HashMap<ConnectionId, UploadSftpLane>>>,
}
impl FsRequestProcessor {
pub(crate) fn new(
file_system: Arc<dyn ExecutorFileSystem>,
fs_watch_manager: FsWatchManager,
codex_home: PathBuf,
) -> Self {
Self {
file_system,
fs_watch_manager,
codex_home,
upload_paths_by_connection: Arc::new(Mutex::new(HashMap::new())),
upload_sftp_lanes: Arc::new(Mutex::new(HashMap::new())),
}
}
pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
self.fs_watch_manager.connection_closed(connection_id).await;
self.upload_paths_by_connection
.lock()
.await
.remove(&connection_id);
self.upload_sftp_lanes.lock().await.remove(&connection_id);
}
pub(crate) async fn read_file(
@@ -82,6 +106,81 @@ impl FsRequestProcessor {
Ok(FsWriteFileResponse {})
}
pub(crate) async fn create_upload(
&self,
connection_id: ConnectionId,
params: FsCreateUploadParams,
) -> Result<FsCreateUploadResponse, JSONRPCErrorError> {
let file_name = sanitize_upload_file_name(&params.file_name)?;
let upload_dir = absolute_path(
self.codex_home
.join("uploads")
.join(Uuid::now_v7().to_string()),
)?;
let upload_path = absolute_path(upload_dir.as_path().join(file_name))?;
self.file_system
.create_directory(
&upload_dir,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await
.map_err(map_fs_error)?;
self.upload_paths_for_connection(connection_id)
.await
.lock()
.await
.insert(upload_path.as_path().to_path_buf());
Ok(FsCreateUploadResponse { path: upload_path })
}
pub(crate) async fn process_upload_sftp_bytes(
&self,
connection_id: ConnectionId,
binary_writer: tokio::sync::mpsc::Sender<Vec<u8>>,
bytes: Vec<u8>,
) -> Result<(), JSONRPCErrorError> {
let lane = if let Some(lane) = self.upload_sftp_lanes.lock().await.get(&connection_id) {
lane.clone()
} else {
let lane = UploadSftpLane::start(
self.upload_paths_for_connection(connection_id).await,
binary_writer.clone(),
)
.await;
self.upload_sftp_lanes
.lock()
.await
.entry(connection_id)
.or_insert_with(|| lane.clone())
.clone()
};
match lane.send(bytes.clone()).await {
Ok(()) => Ok(()),
Err(_) => {
let lane = UploadSftpLane::start(
self.upload_paths_for_connection(connection_id).await,
binary_writer,
)
.await;
self.upload_sftp_lanes
.lock()
.await
.insert(connection_id, lane.clone());
lane.send(bytes).await
}
}
}
async fn upload_paths_for_connection(&self, connection_id: ConnectionId) -> UploadPaths {
self.upload_paths_by_connection
.lock()
.await
.entry(connection_id)
.or_insert_with(|| Arc::new(Mutex::new(HashSet::new())))
.clone()
}
pub(crate) async fn create_directory(
&self,
params: FsCreateDirectoryParams,
@@ -191,6 +290,18 @@ impl FsRequestProcessor {
}
}
fn sanitize_upload_file_name(file_name: &str) -> Result<&str, JSONRPCErrorError> {
Path::new(file_name)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.ok_or_else(|| invalid_request("fs/createUpload requires a fileName".to_string()))
}
fn absolute_path(path: PathBuf) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
AbsolutePathBuf::try_from(path).map_err(|err| internal_error(err.to_string()))
}
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
if err.kind() == io::ErrorKind::InvalidInput {
invalid_request(err.to_string())

View File

@@ -0,0 +1,304 @@
use crate::error_code::internal_error;
use crate::transport::CHANNEL_CAPACITY;
use codex_app_server_protocol::JSONRPCErrorError;
use russh_sftp::protocol::Attrs;
use russh_sftp::protocol::FileAttributes;
use russh_sftp::protocol::Handle;
use russh_sftp::protocol::OpenFlags;
use russh_sftp::protocol::Status;
use russh_sftp::protocol::StatusCode;
use russh_sftp::server::Handler;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs::File;
use tokio::fs::OpenOptions;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncSeekExt;
use tokio::io::AsyncWriteExt;
use tokio::io::SeekFrom;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
const UPLOAD_SFTP_STREAM_BUFFER_BYTES: usize = 64 * 1024;
#[derive(Clone)]
pub(crate) struct UploadSftpLane {
incoming_tx: mpsc::Sender<Vec<u8>>,
}
impl UploadSftpLane {
pub(crate) async fn start(
allowed_paths: Arc<Mutex<HashSet<PathBuf>>>,
binary_writer: mpsc::Sender<Vec<u8>>,
) -> Self {
let (server_stream, bridge_stream) = tokio::io::duplex(UPLOAD_SFTP_STREAM_BUFFER_BYTES);
russh_sftp::server::run(server_stream, UploadSftpHandler::new(allowed_paths)).await;
let (mut bridge_reader, mut bridge_writer) = tokio::io::split(bridge_stream);
let (incoming_tx, mut incoming_rx) = mpsc::channel::<Vec<u8>>(CHANNEL_CAPACITY);
tokio::spawn(async move {
while let Some(bytes) = incoming_rx.recv().await {
if bridge_writer.write_all(&bytes).await.is_err() {
break;
}
}
});
tokio::spawn(async move {
let mut buffer = vec![0; UPLOAD_SFTP_STREAM_BUFFER_BYTES];
loop {
match bridge_reader.read(&mut buffer).await {
Ok(0) | Err(_) => break,
Ok(bytes_read) => {
if binary_writer
.send(buffer[..bytes_read].to_vec())
.await
.is_err()
{
break;
}
}
}
}
});
Self { incoming_tx }
}
pub(crate) async fn send(&self, bytes: Vec<u8>) -> Result<(), JSONRPCErrorError> {
self.incoming_tx
.send(bytes)
.await
.map_err(|_| internal_error("staged upload SFTP lane is closed".to_string()))
}
}
struct UploadSftpHandler {
allowed_paths: Arc<Mutex<HashSet<PathBuf>>>,
handles: HashMap<String, UploadHandle>,
next_handle_id: u64,
}
struct UploadHandle {
file: File,
}
#[derive(Clone, Copy)]
enum UploadSftpError {
NoSuchFile,
PermissionDenied,
Failure,
Unsupported,
}
impl From<UploadSftpError> for StatusCode {
fn from(value: UploadSftpError) -> Self {
match value {
UploadSftpError::NoSuchFile => Self::NoSuchFile,
UploadSftpError::PermissionDenied => Self::PermissionDenied,
UploadSftpError::Failure => Self::Failure,
UploadSftpError::Unsupported => Self::OpUnsupported,
}
}
}
impl From<io::Error> for UploadSftpError {
fn from(err: io::Error) -> Self {
match err.kind() {
io::ErrorKind::NotFound => Self::NoSuchFile,
io::ErrorKind::PermissionDenied => Self::PermissionDenied,
_ => Self::Failure,
}
}
}
impl UploadSftpHandler {
fn new(allowed_paths: Arc<Mutex<HashSet<PathBuf>>>) -> Self {
Self {
allowed_paths,
handles: HashMap::new(),
next_handle_id: 0,
}
}
async fn is_allowed_path(&self, path: &str) -> bool {
self.allowed_paths
.lock()
.await
.contains(&PathBuf::from(path))
}
fn next_handle(&mut self) -> String {
let handle = self.next_handle_id.to_string();
self.next_handle_id = self.next_handle_id.wrapping_add(1);
handle
}
}
impl Handler for UploadSftpHandler {
type Error = UploadSftpError;
fn unimplemented(&self) -> Self::Error {
UploadSftpError::Unsupported
}
async fn open(
&mut self,
id: u32,
filename: String,
pflags: OpenFlags,
_attrs: FileAttributes,
) -> Result<Handle, Self::Error> {
if !self.is_allowed_path(&filename).await {
return Err(UploadSftpError::PermissionDenied);
}
let mut options = OpenOptions::new();
options.write(pflags.contains(OpenFlags::WRITE));
options.create(pflags.contains(OpenFlags::CREATE));
options.truncate(pflags.contains(OpenFlags::TRUNCATE));
options.append(pflags.contains(OpenFlags::APPEND));
let file = options.open(&filename).await?;
let handle = self.next_handle();
self.handles.insert(handle.clone(), UploadHandle { file });
Ok(Handle { id, handle })
}
async fn write(
&mut self,
id: u32,
handle: String,
offset: u64,
data: Vec<u8>,
) -> Result<Status, Self::Error> {
let upload_handle = self
.handles
.get_mut(&handle)
.ok_or(UploadSftpError::NoSuchFile)?;
upload_handle.file.seek(SeekFrom::Start(offset)).await?;
upload_handle.file.write_all(&data).await?;
Ok(ok_status(id))
}
async fn close(&mut self, id: u32, handle: String) -> Result<Status, Self::Error> {
let mut upload_handle = self
.handles
.remove(&handle)
.ok_or(UploadSftpError::NoSuchFile)?;
upload_handle.file.flush().await?;
Ok(ok_status(id))
}
async fn stat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
if !self.is_allowed_path(&path).await {
return Err(UploadSftpError::PermissionDenied);
}
let metadata = tokio::fs::metadata(path).await?;
let mut attrs = FileAttributes::empty();
attrs.size = Some(metadata.len());
Ok(Attrs { id, attrs })
}
}
fn ok_status(id: u32) -> Status {
Status {
id,
status_code: StatusCode::Ok,
error_message: "Ok".to_string(),
language_tag: "en-US".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use russh_sftp::client::SftpSession;
use tempfile::TempDir;
#[tokio::test]
async fn writes_only_paths_allocated_for_uploads() {
let tempdir = TempDir::new().expect("create temp dir");
let path = tempdir.path().join("uploads").join("note.txt");
tokio::fs::create_dir_all(path.parent().expect("path parent"))
.await
.expect("create upload parent");
let allowed_paths = Arc::new(Mutex::new(HashSet::from([path.clone()])));
let (client_stream, server_stream) = tokio::io::duplex(UPLOAD_SFTP_STREAM_BUFFER_BYTES);
russh_sftp::server::run(server_stream, UploadSftpHandler::new(allowed_paths)).await;
let session = SftpSession::new(client_stream)
.await
.expect("initialize sftp");
let mut file = session
.create(path.to_string_lossy())
.await
.expect("open staged upload");
file.write_all(b"hello").await.expect("write staged upload");
file.shutdown().await.expect("close staged upload");
assert_eq!(
tokio::fs::read_to_string(path).await.expect("read upload"),
"hello"
);
}
#[tokio::test]
async fn rejects_paths_that_were_not_allocated() {
let tempdir = TempDir::new().expect("create temp dir");
let path = tempdir.path().join("uploads").join("note.txt");
tokio::fs::create_dir_all(path.parent().expect("path parent"))
.await
.expect("create upload parent");
let (client_stream, server_stream) = tokio::io::duplex(UPLOAD_SFTP_STREAM_BUFFER_BYTES);
russh_sftp::server::run(
server_stream,
UploadSftpHandler::new(Arc::new(Mutex::new(HashSet::new()))),
)
.await;
let session = SftpSession::new(client_stream)
.await
.expect("initialize sftp");
let err = match session.create(path.to_string_lossy()).await {
Ok(_) => panic!("path should be rejected"),
Err(err) => err,
};
assert!(err.to_string().contains("Permission denied"));
assert!(!path.exists());
}
#[tokio::test]
async fn accepts_create_attributes_for_allocated_paths() {
let tempdir = TempDir::new().expect("create temp dir");
let path = tempdir.path().join("uploads").join("note.txt");
tokio::fs::create_dir_all(path.parent().expect("path parent"))
.await
.expect("create upload parent");
let allowed_paths = Arc::new(Mutex::new(HashSet::from([path.clone()])));
let (client_stream, server_stream) = tokio::io::duplex(UPLOAD_SFTP_STREAM_BUFFER_BYTES);
russh_sftp::server::run(server_stream, UploadSftpHandler::new(allowed_paths)).await;
let session = SftpSession::new(client_stream)
.await
.expect("initialize sftp");
let mut attrs = FileAttributes::empty();
attrs.size = Some(0);
let mut file = session
.open_with_flags_and_attributes(
path.to_string_lossy(),
OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE,
attrs,
)
.await
.expect("open staged upload with attrs");
file.write_all(b"hello").await.expect("write staged upload");
file.shutdown().await.expect("close staged upload");
assert_eq!(
tokio::fs::read_to_string(path).await.expect("read upload"),
"hello"
);
}
}

View File

@@ -31,6 +31,7 @@ pub(crate) struct ConnectionState {
pub(crate) outbound_initialized: Arc<AtomicBool>,
pub(crate) outbound_experimental_api_enabled: Arc<AtomicBool>,
pub(crate) outbound_opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
pub(crate) binary_writer: Option<mpsc::Sender<Vec<u8>>>,
pub(crate) session: Arc<ConnectionSessionState>,
}
@@ -40,11 +41,13 @@ impl ConnectionState {
outbound_initialized: Arc<AtomicBool>,
outbound_experimental_api_enabled: Arc<AtomicBool>,
outbound_opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
binary_writer: Option<mpsc::Sender<Vec<u8>>>,
) -> Self {
Self {
outbound_initialized,
outbound_experimental_api_enabled,
outbound_opted_out_notification_methods,
binary_writer,
session: Arc::new(ConnectionSessionState::new()),
}
}

View File

@@ -27,6 +27,7 @@ use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsCreateUploadParams;
use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadFileParams;
@@ -988,6 +989,14 @@ impl McpProcess {
self.send_request("fs/writeFile", params).await
}
pub async fn send_fs_create_upload_request(
&mut self,
params: FsCreateUploadParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/createUpload", params).await
}
pub async fn send_fs_create_directory_request(
&mut self,
params: FsCreateDirectoryParams,

View File

@@ -6,6 +6,8 @@ use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::FsChangedNotification;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsCreateUploadParams;
use codex_app_server_protocol::FsCreateUploadResponse;
use codex_app_server_protocol::FsGetMetadataResponse;
use codex_app_server_protocol::FsReadDirectoryEntry;
use codex_app_server_protocol::FsReadFileResponse;
@@ -299,6 +301,37 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_create_upload_allocates_codex_managed_storage() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = initialized_mcp(&codex_home).await?;
let request_id = mcp
.send_fs_create_upload_request(FsCreateUploadParams {
file_name: "../note.txt".to_string(),
})
.await?;
let response: FsCreateUploadResponse = to_response(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
let path = response.path.as_path();
let parent = path.parent().expect("upload path should have a parent");
let canonical_parent = std::fs::canonicalize(parent)?;
let canonical_codex_home = std::fs::canonicalize(codex_home.path())?;
assert!(canonical_parent.starts_with(canonical_codex_home.join("uploads")));
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some("note.txt")
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_write_file_accepts_base64_bytes() -> Result<()> {
let codex_home = TempDir::new()?;