4 Commits

Author SHA1 Message Date
aa902fdd90 Add healthcheck phase to build console, remove unused Jupyter test script 2026-06-26 02:11:51 +06:00
d721b3fb94 v0.3.0 — update versions, Rust dependencies, and formatting 2026-06-26 02:11:46 +06:00
4bf19f11a9 Merge branch 'dev' of git.omukk.dev:wrenn/wrenn into dev 2026-06-26 02:10:10 +06:00
946fbecf4a feat: run commands in each sandbox's own default shell
Commands and template builds now run in the user's login shell taken
from the sandbox image, instead of always forcing sh. On Ubuntu that
means bash features work in build steps; other images use their own
default shell. Exec, streaming exec, the terminal, and admin template
builds all follow the same rule.

The terminal can also open as a specific user now, and a terminal opened
with no command starts that user's login shell.
2026-06-23 23:11:25 +06:00
26 changed files with 563 additions and 644 deletions

View File

@ -1 +1 @@
0.2.2
0.3.0

View File

@ -1 +1 @@
0.2.2
0.3.0

430
envd-rs/Cargo.lock generated
View File

@ -124,9 +124,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
@ -195,9 +195,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
@ -258,24 +258,24 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.20.2"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.11.1"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
dependencies = [
"serde",
]
[[package]]
name = "cc"
version = "1.2.61"
version = "1.2.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
dependencies = [
"find-msvc-tools",
"jobserver",
@ -297,9 +297,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
@ -489,9 +489,9 @@ dependencies = [
[[package]]
name = "dashmap"
version = "6.1.0"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
@ -514,9 +514,9 @@ dependencies = [
[[package]]
name = "either"
version = "1.15.0"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "encoding_rs"
@ -529,7 +529,7 @@ dependencies = [
[[package]]
name = "envd"
version = "0.4.1"
version = "0.5.0"
dependencies = [
"async-stream",
"axum",
@ -592,13 +592,12 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "filetime"
version = "0.2.27"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
@ -759,22 +758,20 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.4.2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
[[package]]
name = "h2"
version = "0.4.13"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
@ -807,9 +804,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.17.0"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
@ -834,9 +831,9 @@ dependencies = [
[[package]]
name = "http"
version = "1.4.0"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
@ -885,9 +882,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.9.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
@ -931,7 +928,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
"windows-core 0.62.2",
]
[[package]]
@ -943,12 +940,6 @@ dependencies = [
"cc",
]
[[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"
@ -956,9 +947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.0",
"serde",
"serde_core",
"hashbrown 0.17.1",
]
[[package]]
@ -1014,21 +1003,20 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.98"
version = "0.3.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "kqueue"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
dependencies = [
"kqueue-sys",
"libc",
@ -1036,11 +1024,11 @@ dependencies = [
[[package]]
name = "kqueue-sys"
version = "1.0.4"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 1.3.2",
"bitflags 2.13.0",
"libc",
]
@ -1050,30 +1038,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[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 = "libredox"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"bitflags 2.11.1",
"libc",
"plain",
"redox_syscall 0.7.4",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@ -1091,9 +1061,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "matchers"
@ -1112,9 +1082,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.0"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "mime"
@ -1144,9 +1114,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"log",
@ -1177,7 +1147,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"cfg-if",
"cfg_aliases",
"libc",
@ -1189,7 +1159,7 @@ version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"filetime",
"fsevent-sys",
"inotify",
@ -1268,7 +1238,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"redox_syscall",
"smallvec",
"windows-link",
]
@ -1281,18 +1251,18 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project"
version = "1.1.11"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.11"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
@ -1311,12 +1281,6 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "prettyplease"
version = "0.2.37"
@ -1338,9 +1302,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.45"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
@ -1383,16 +1347,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "redox_syscall"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
]
[[package]]
@ -1408,9 +1363,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.8.10"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustix"
@ -1418,7 +1373,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"errno",
"libc",
"linux-raw-sys",
@ -1452,12 +1407,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[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"
@ -1490,9 +1439,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@ -1546,9 +1495,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.3.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
@ -1574,15 +1523,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.3"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
@ -1608,9 +1557,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
@ -1644,7 +1593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@ -1681,9 +1630,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.52.1"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
@ -1739,11 +1688,11 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.8"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.11.1",
"bitflags 2.13.0",
"bytes",
"futures-core",
"futures-util",
@ -1760,7 +1709,6 @@ dependencies = [
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@ -1852,9 +1800,9 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.20.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
@ -1868,12 +1816,6 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
@ -1910,27 +1852,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.121"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
dependencies = [
"cfg-if",
"once_cell",
@ -1941,9 +1874,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.121"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@ -1951,9 +1884,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.121"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
dependencies = [
"bumpalo",
"proc-macro2",
@ -1964,47 +1897,13 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.121"
version = "0.2.125"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "winapi"
version = "0.3.9"
@ -2042,7 +1941,7 @@ version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core",
"windows-core 0.57.0",
"windows-targets",
]
@ -2052,12 +1951,25 @@ version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement",
"windows-interface",
"windows-result",
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link",
"windows-result 0.4.1",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
@ -2069,6 +1981,17 @@ dependencies = [
"syn",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
@ -2080,6 +2003,17 @@ dependencies = [
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
@ -2095,6 +2029,24 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@ -2177,114 +2129,26 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zeroize"
version = "1.8.2"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",

View File

@ -1,6 +1,6 @@
[package]
name = "envd"
version = "0.4.1"
version = "0.5.0"
edition = "2024"
rust-version = "1.95"

View File

@ -30,8 +30,5 @@ pub async fn get_activity(State(state): State<Arc<AppState>>) -> impl IntoRespon
disk_bps: state.disk_bps(),
};
(
[(header::CACHE_CONTROL, "no-store")],
Json(body),
)
([(header::CACHE_CONTROL, "no-store")], Json(body))
}

View File

@ -346,7 +346,10 @@ fn step_realtime_clock(nanos: i64) {
tracing::warn!(error = %std::io::Error::last_os_error(),
"clock_settime(CLOCK_REALTIME) failed");
} else {
tracing::info!(nanos, "stepped CLOCK_REALTIME from host timestamp on resume");
tracing::info!(
nanos,
"stepped CLOCK_REALTIME from host timestamp on resume"
);
}
}

View File

@ -336,7 +336,12 @@ test -f "${HOME}/.bashrc" && . "${HOME}/.bashrc""#;
// ($1 is cmd_str; $0 of the inner shell is the shell path).
// - with args: exec the program + args directly, no shell interpretation
// (backward-compatible program/argv form).
let target = if args.is_empty() {
let target = if cmd_str.is_empty() && args.is_empty() {
// No command at all (e.g. an interactive PTY session with no explicit
// command): launch the user's login shell directly. Under a pty its
// stdin is a tty, so it starts interactively.
format!(r#""{shell}""#)
} else if args.is_empty() {
format!(r#""{shell}" -c "$1" "{shell}""#)
} else {
r#""$@""#.to_string()
@ -388,7 +393,7 @@ exec {nice_prefix}{target}"#
let master_fd = pty_result.master;
let slave_fd = pty_result.slave;
let mut command = std::process::Command::new("/bin/bash");
let mut command = std::process::Command::new(&shell);
command
.args(&wrapper_args)
.env_clear()
@ -488,7 +493,7 @@ exec {nice_prefix}{target}"#
end_rx,
})
} else {
let mut command = std::process::Command::new("/bin/bash");
let mut command = std::process::Command::new(&shell);
command
.args(&wrapper_args)
.env_clear()

View File

@ -72,7 +72,12 @@ impl ProcessServiceImpl {
ConnectError::new(ErrorCode::InvalidArgument, "process config required")
})?;
let username = self.state.defaults.user();
// Per-request user overrides the sandbox default when provided.
let username = if proc_config.user.is_empty() {
self.state.defaults.user()
} else {
proc_config.user.to_string()
};
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
let cmd_raw: &str = proc_config.cmd;

View File

@ -2,7 +2,7 @@ import { apiFetch, apiFetchMultipart, type ApiResult } from '$lib/api/client';
export type BuildLogEntry = {
step: number;
phase: string; // "pre-build", "recipe", or "post-build"
phase: string; // "pre-build", "recipe", "post-build", or "healthcheck"
cmd: string;
stdout: string;
stderr: string;

View File

@ -103,7 +103,11 @@ export function createBuildConsole(buildId: string) {
exit: ev.exit ?? 0,
elapsedMs: ev.elapsed_ms ?? 0
});
if (typeof ev.step === 'number' && ev.step > currentStep) currentStep = ev.step;
// The healthcheck is shown as a trailing pseudo-step but is not
// counted in total_steps, so it must not advance the counter.
if (ev.phase !== 'healthcheck' && typeof ev.step === 'number' && ev.step > currentStep) {
currentStep = ev.step;
}
break;
}
case 'build-status':

View File

@ -27,6 +27,8 @@
return 'var(--color-text-bright)';
case 'WORKDIR':
return 'var(--color-text-tertiary)';
case 'HEALTHCHECK':
return 'var(--color-accent-bright)';
default:
return 'var(--color-text-muted)';
}
@ -111,9 +113,8 @@
{/if}
</div>
<code class="mt-1.5 block truncate font-mono text-meta">
<span style="color: {keywordColor(kw)}">{kw}</span>{#if rest}
<span class="text-[var(--color-text-secondary)]">{rest}</span>
{/if}
<span style="color: {keywordColor(kw)}">{kw}</span>{#if rest}{' '}<span
class="text-[var(--color-text-secondary)]">{rest}</span>{/if}
</code>
</div>
{/each}

View File

@ -278,7 +278,7 @@
if (tag) {
msg.tag = tag;
} else {
msg.cmd = '/bin/bash';
// No cmd: the server launches the user's default login shell.
msg.envs = { TERM: 'xterm-256color' };
}
wsSend(ws, JSON.stringify(msg));

View File

@ -23,7 +23,6 @@ import (
const (
ptyKeepaliveInterval = 30 * time.Second
ptyDefaultCmd = "/bin/bash"
ptyDefaultCols = 80
ptyDefaultRows = 24
)
@ -171,10 +170,9 @@ func (h *ptyHandler) handleStart(
sandboxIDStr string,
msg wsPtyIn,
) {
// An empty cmd is intentional: envd launches the user's default login shell
// (resolved from /etc/passwd) when no command is given.
cmd := msg.Cmd
if cmd == "" {
cmd = ptyDefaultCmd
}
cols := msg.Cols
if cols == 0 {
cols = ptyDefaultCols
@ -237,6 +235,7 @@ func (h *ptyHandler) handleConnect(
stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{
SandboxId: sandboxIDStr,
Tag: msg.Tag,
Reconnect: true,
}))
if err != nil {
ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to connect to pty: " + err.Error(), Fatal: true})

View File

@ -2,7 +2,7 @@ openapi: "3.1.0"
info:
title: Wrenn API
description: API for Wrenn — the runtime where AI coding agents live, work, and ship.
version: "0.2.2"
version: "0.3.0"
servers:
- url: http://localhost:8080
@ -2868,13 +2868,13 @@ paths:
parameters:
- name: before
in: query
schema: {type: string, format: date-time}
schema: { type: string, format: date-time }
- name: before_id
in: query
schema: {type: string}
schema: { type: string }
- name: limit
in: query
schema: {type: integer, minimum: 1, maximum: 200, default: 50}
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
responses:
"200":
description: Audit log page (all teams)
@ -2947,7 +2947,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
requestBody:
required: true
content:
@ -2980,7 +2980,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
responses:
"204":
description: Deleted
@ -3031,7 +3031,7 @@ paths:
application/json:
schema:
type: array
items: {type: object}
items: { type: object }
/v1/admin/users/{id}/active:
put:
@ -3044,7 +3044,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
requestBody:
required: true
content:
@ -3053,7 +3053,7 @@ paths:
type: object
required: [active]
properties:
active: {type: boolean}
active: { type: boolean }
responses:
"204":
description: Updated
@ -3086,7 +3086,7 @@ paths:
- name: name
in: path
required: true
schema: {type: string}
schema: { type: string }
responses:
"204":
description: Deleted
@ -3102,13 +3102,13 @@ paths:
required: true
content:
application/json:
schema: {type: object}
schema: { type: object }
responses:
"201":
description: Build created
content:
application/json:
schema: {type: object}
schema: { type: object }
get:
summary: List builds (admin)
operationId: adminListBuilds
@ -3122,7 +3122,7 @@ paths:
application/json:
schema:
type: array
items: {type: object}
items: { type: object }
/v1/admin/builds/{id}:
get:
@ -3135,13 +3135,13 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
responses:
"200":
description: Build detail
content:
application/json:
schema: {type: object}
schema: { type: object }
/v1/admin/builds/{id}/cancel:
post:
@ -3154,7 +3154,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
responses:
"204":
description: Cancelled
@ -3174,7 +3174,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
responses:
"101":
description: WebSocket upgrade — streams build console events
@ -3220,7 +3220,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
get:
summary: Get capsule detail (admin)
operationId: adminGetCapsule
@ -3271,7 +3271,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
requestBody:
required: false
content:
@ -3301,7 +3301,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
post:
summary: Execute a command on any capsule (admin)
operationId: adminExecCommand
@ -3341,7 +3341,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
get:
summary: Get per-capsule resource metrics (admin)
operationId: adminGetCapsuleMetrics
@ -3375,7 +3375,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
get:
summary: List running processes on any capsule (admin)
operationId: adminListProcesses
@ -3403,11 +3403,11 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
- name: selector
in: path
required: true
schema: {type: string}
schema: { type: string }
description: Process PID (numeric) or tag (string)
delete:
summary: Kill a process on any capsule (admin)
@ -3440,7 +3440,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
post:
summary: Upload a file to any capsule (admin)
operationId: adminUploadFile
@ -3455,8 +3455,8 @@ paths:
type: object
required: [path, file]
properties:
path: {type: string}
file: {type: string, format: binary}
path: { type: string }
file: { type: string, format: binary }
responses:
"204":
description: File uploaded
@ -3480,7 +3480,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
post:
summary: Download a file from any capsule (admin)
operationId: adminDownloadFile
@ -3515,7 +3515,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
post:
summary: List directory contents on any capsule (admin)
operationId: adminListDir
@ -3549,7 +3549,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
post:
summary: Create a directory on any capsule (admin)
operationId: adminMakeDir
@ -3583,7 +3583,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
post:
summary: Remove a file or directory on any capsule (admin)
operationId: adminRemovePath
@ -3613,7 +3613,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
get:
summary: Stream command execution on any capsule via WebSocket (admin)
operationId: adminExecStream
@ -3637,7 +3637,7 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
get:
summary: Interactive PTY session on any capsule via WebSocket (admin)
operationId: adminPtySession
@ -3662,11 +3662,11 @@ paths:
- name: id
in: path
required: true
schema: {type: string}
schema: { type: string }
- name: selector
in: path
required: true
schema: {type: string}
schema: { type: string }
description: Process PID (numeric) or tag (string)
get:
summary: Stream process output on any capsule via WebSocket (admin)
@ -3867,7 +3867,7 @@ components:
to 60 (the agent's startup envelope).
metadata:
type: object
additionalProperties: {type: string}
additionalProperties: { type: string }
nullable: true
description: >
Optional user-supplied key/value labels attached to the capsule.
@ -3952,7 +3952,21 @@ components:
type: string
status:
type: string
enum: [pending, starting, running, pausing, paused, snapshotting, resuming, stopping, hibernated, stopped, missing, error]
enum:
[
pending,
starting,
running,
pausing,
paused,
snapshotting,
resuming,
stopping,
hibernated,
stopped,
missing,
error,
]
template:
type: string
vcpus:
@ -3977,7 +3991,7 @@ components:
format: date-time
metadata:
type: object
additionalProperties: {type: string}
additionalProperties: { type: string }
nullable: true
description: |
User-supplied key/value labels attached at create-time. Once the
@ -4037,7 +4051,7 @@ components:
cannot be deleted.
metadata:
type: object
additionalProperties: {type: string}
additionalProperties: { type: string }
nullable: true
AdminTemplate:
@ -4722,15 +4736,15 @@ components:
AuditLogEntry:
type: object
properties:
id: {type: string}
actor_type: {type: string, enum: [user, api_key, host, system]}
actor_id: {type: string}
actor_name: {type: string}
resource_type: {type: string}
resource_id: {type: string}
action: {type: string}
scope: {type: string}
status: {type: string, enum: [success, failure]}
id: { type: string }
actor_type: { type: string, enum: [user, api_key, host, system] }
actor_id: { type: string }
actor_name: { type: string }
resource_type: { type: string }
resource_id: { type: string }
action: { type: string }
scope: { type: string }
status: { type: string, enum: [success, failure] }
metadata:
type: object
additionalProperties: true
@ -4767,17 +4781,17 @@ components:
resource:
type: object
properties:
id: {type: string}
type: {type: string}
id: { type: string }
type: { type: string }
actor:
type: object
properties:
type: {type: string, enum: [user, api_key, system]}
id: {type: string}
name: {type: string}
type: { type: string, enum: [user, api_key, system] }
id: { type: string }
name: { type: string }
metadata:
type: object
additionalProperties: {type: string}
additionalProperties: { type: string }
description: |
Event-specific context. Examples: `reason` (ttl_expired,
host_failure, cleanup_after_create_error, orphaned),

View File

@ -23,14 +23,14 @@ type PtyEvent struct {
// PtyStart starts a new PTY process in the guest and returns a channel of events.
// The tag is the stable identifier used to reconnect via PtyConnect.
// The channel is closed when the process ends or ctx is cancelled.
// NOTE: The user parameter from PtyAttachRequest is not yet supported by envd's
// ProcessConfig proto. When envd adds user support, thread it through here.
func (c *Client) PtyStart(ctx context.Context, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan PtyEvent, error) {
// An empty user runs as the sandbox default user.
func (c *Client) PtyStart(ctx context.Context, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd, user string) (<-chan PtyEvent, error) {
stdin := true
cfg := &envdpb.ProcessConfig{
Cmd: cmd,
Args: args,
Envs: envs,
User: user,
}
if cwd != "" {
cfg.Cwd = &cwd

View File

@ -682,7 +682,7 @@ func (s *Server) PtyAttach(
) error {
msg := req.Msg
events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd)
events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd, msg.User, msg.Reconnect)
if err != nil {
return connect.NewError(connect.CodeInternal, fmt.Errorf("pty attach: %w", err))
}

View File

@ -21,30 +21,33 @@ type ExecContext struct {
var envRegex = regexp.MustCompile(`\$\$|\$\{([a-zA-Z0-9_]*)\}|\$([a-zA-Z0-9_]+)`)
// WrappedCommand returns the full shell command for a RUN step with context
// applied. The result is passed as the argument to /bin/sh -c.
// applied. The result is handed to the exec layer as a bare command (no
// "-c" wrapper), so the user's default login shell — resolved by envd from
// /etc/passwd inside the VM — interprets it.
//
// If WORKDIR and/or ENV are set, they are prepended as a shell preamble:
//
// cd '/the/dir' && KEY='val' /bin/sh -c 'original command'
// cd '/the/dir' && export KEY='val' && original command
//
// If USER is set to a non-root user, the entire command is wrapped with su:
// If USER is set to a non-root user, the entire command is wrapped with su.
// Dropping `-s` lets su run it under that user's login shell rather than
// forcing /bin/sh:
//
// su <user> -s /bin/sh -c '<preamble + command>'
// su <user> -c '<preamble + command>'
func (c *ExecContext) WrappedCommand(cmd string) string {
inner := c.innerCommand(cmd)
if c.User != "" && c.User != "root" {
return "su " + shellescape(c.User) + " -s /bin/sh -c " + shellescape(inner)
return "su " + shellescape(c.User) + " -c " + shellescape(inner)
}
return inner
}
// innerCommand builds the command with workdir/env preamble but without user wrapping.
// innerCommand applies the workdir/env preamble to cmd without user wrapping.
// The preamble cds into WORKDIR and exports ENV vars, then the command runs in
// the same (login) shell — no nested shell is named, so the user's default
// shell interprets the command and any pipes/operators within it.
func (c *ExecContext) innerCommand(cmd string) string {
prefix := c.shellPrefix()
if prefix == "" {
return cmd
}
return prefix + "/bin/sh -c " + shellescape(cmd)
return c.shellPrefix() + cmd
}
// StartCommand returns the shell command for a START step. The process is
@ -55,16 +58,22 @@ func (c *ExecContext) innerCommand(cmd string) string {
// Multiple START steps can be issued to run several background processes
// simultaneously before a healthcheck is evaluated.
func (c *ExecContext) StartCommand(cmd string) string {
// Launch the background process under the user's login shell. $SHELL is set
// to that shell by su (non-root) or by envd (root), with /bin/sh as a safe
// fallback if it is somehow unset.
prefix := c.shellPrefix()
inner := prefix + "nohup /bin/sh -c " + shellescape(cmd) + " >/dev/null 2>&1 &"
inner := prefix + `nohup "${SHELL:-/bin/sh}" -c ` + shellescape(cmd) + " >/dev/null 2>&1 &"
if c.User != "" && c.User != "root" {
return "su " + shellescape(c.User) + " -s /bin/sh -c " + shellescape(inner)
return "su " + shellescape(c.User) + " -c " + shellescape(inner)
}
return inner
}
// shellPrefix builds the "cd ... && KEY=val " preamble for a shell command.
// Returns an empty string when no context is set.
// shellPrefix builds the "cd '/dir' && export KEY='val' && " preamble for a
// shell command. ENV vars are exported (not just assignment-prefixed) so they
// apply to the whole command — including any pipes or && chains — and to child
// processes, matching Dockerfile ENV semantics. Returns an empty string when no
// context is set.
func (c *ExecContext) shellPrefix() string {
if c.WorkDir == "" && len(c.EnvVars) == 0 {
return ""
@ -75,16 +84,20 @@ func (c *ExecContext) shellPrefix() string {
sb.WriteString(shellescape(c.WorkDir))
sb.WriteString(" && ")
}
keys := make([]string, 0, len(c.EnvVars))
for k := range c.EnvVars {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
sb.WriteString(k)
sb.WriteByte('=')
sb.WriteString(shellescape(c.EnvVars[k]))
sb.WriteByte(' ')
if len(c.EnvVars) > 0 {
keys := make([]string, 0, len(c.EnvVars))
for k := range c.EnvVars {
keys = append(keys, k)
}
slices.Sort(keys)
sb.WriteString("export")
for _, k := range keys {
sb.WriteByte(' ')
sb.WriteString(k)
sb.WriteByte('=')
sb.WriteString(shellescape(c.EnvVars[k]))
}
sb.WriteString(" && ")
}
return sb.String()
}

View File

@ -20,31 +20,31 @@ func TestExecContext_WrappedCommand(t *testing.T) {
name: "workdir only",
ctx: ExecContext{WorkDir: "/app"},
cmd: "npm install",
want: "cd '/app' && /bin/sh -c 'npm install'",
want: "cd '/app' && npm install",
},
{
name: "env only",
ctx: ExecContext{EnvVars: map[string]string{"PORT": "8080"}},
cmd: "node server.js",
want: "PORT='8080' /bin/sh -c 'node server.js'",
want: "export PORT='8080' && node server.js",
},
{
name: "workdir with space",
ctx: ExecContext{WorkDir: "/my project"},
cmd: "make build",
want: "cd '/my project' && /bin/sh -c 'make build'",
want: "cd '/my project' && make build",
},
{
name: "command with single quotes",
ctx: ExecContext{WorkDir: "/app"},
cmd: "echo 'hello'",
want: "cd '/app' && /bin/sh -c 'echo '\\''hello'\\'''",
want: "cd '/app' && echo 'hello'",
},
{
name: "env value with single quotes",
ctx: ExecContext{EnvVars: map[string]string{"MSG": "it's fine"}},
cmd: "echo $MSG",
want: "MSG='it'\\''s fine' /bin/sh -c 'echo $MSG'",
want: "export MSG='it'\\''s fine' && echo $MSG",
},
{
name: "env expansion with pre-expanded PATH",
@ -52,7 +52,25 @@ func TestExecContext_WrappedCommand(t *testing.T) {
EnvVars: map[string]string{"PATH": "/usr/bin", "FOO": "/opt/venv/bin:/usr/bin"},
},
cmd: "make build",
want: "FOO='/opt/venv/bin:/usr/bin' PATH='/usr/bin' /bin/sh -c 'make build'",
want: "export FOO='/opt/venv/bin:/usr/bin' PATH='/usr/bin' && make build",
},
{
name: "non-root user wraps with su login shell",
ctx: ExecContext{User: "wrenn-user"},
cmd: "whoami",
want: "su 'wrenn-user' -c 'whoami'",
},
{
name: "non-root user with workdir and env",
ctx: ExecContext{User: "wrenn-user", WorkDir: "/app", EnvVars: map[string]string{"PORT": "8080"}},
cmd: "node server.js",
want: "su 'wrenn-user' -c 'cd '\\''/app'\\'' && export PORT='\\''8080'\\'' && node server.js'",
},
{
name: "root user is not su-wrapped",
ctx: ExecContext{User: "root", WorkDir: "/app"},
cmd: "ls",
want: "cd '/app' && ls",
},
}
@ -88,19 +106,25 @@ func TestExecContext_StartCommand(t *testing.T) {
name: "no context",
ctx: ExecContext{},
cmd: "python3 app.py",
want: "nohup /bin/sh -c 'python3 app.py' >/dev/null 2>&1 &",
want: `nohup "${SHELL:-/bin/sh}" -c 'python3 app.py' >/dev/null 2>&1 &`,
},
{
name: "with workdir",
ctx: ExecContext{WorkDir: "/app"},
cmd: "python3 server.py",
want: "cd '/app' && nohup /bin/sh -c 'python3 server.py' >/dev/null 2>&1 &",
want: `cd '/app' && nohup "${SHELL:-/bin/sh}" -c 'python3 server.py' >/dev/null 2>&1 &`,
},
{
name: "with env",
ctx: ExecContext{EnvVars: map[string]string{"PORT": "9000"}},
cmd: "node index.js",
want: "PORT='9000' nohup /bin/sh -c 'node index.js' >/dev/null 2>&1 &",
want: `export PORT='9000' && nohup "${SHELL:-/bin/sh}" -c 'node index.js' >/dev/null 2>&1 &`,
},
{
name: "non-root user wraps start with su",
ctx: ExecContext{User: "wrenn-user"},
cmd: "python3 app.py",
want: `su 'wrenn-user' -c 'nohup "${SHELL:-/bin/sh}" -c '\''python3 app.py'\'' >/dev/null 2>&1 &'`,
},
}

View File

@ -183,8 +183,7 @@ func execRun(
start := time.Now()
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxID,
Cmd: "/bin/sh",
Args: []string{"-c", bctx.WrappedCommand(st.Shell)},
Cmd: bctx.WrappedCommand(st.Shell),
TimeoutSec: int32(timeout.Seconds()),
}))
@ -360,8 +359,7 @@ func execRawShell(
start := time.Now()
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxID,
Cmd: "/bin/sh",
Args: []string{"-c", shellCmd},
Cmd: shellCmd,
TimeoutSec: int32(timeout.Seconds()),
}))
@ -398,8 +396,7 @@ func execStart(
start := time.Now()
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxID,
Cmd: "/bin/sh",
Args: []string{"-c", bctx.StartCommand(st.Shell)},
Cmd: bctx.StartCommand(st.Shell),
TimeoutSec: 10,
}))

View File

@ -721,16 +721,18 @@ func (m *Manager) SetDefaults(ctx context.Context, sandboxID, defaultUser string
}
// PtyAttach starts a new PTY process or reconnects to an existing one.
// If cmd is non-empty, starts a new process. If empty, reconnects using tag.
func (m *Manager) PtyAttach(ctx context.Context, sandboxID, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan envdclient.PtyEvent, error) {
// When reconnect is true it reattaches to the process identified by tag;
// otherwise it starts a new process (cmd may be empty to launch the user's
// default login shell). user empty means the sandbox default user.
func (m *Manager) PtyAttach(ctx context.Context, sandboxID, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd, user string, reconnect bool) (<-chan envdclient.PtyEvent, error) {
c, err := m.activeClient(sandboxID)
if err != nil {
return nil, err
}
if cmd != "" {
return c.PtyStart(ctx, tag, cmd, args, cols, rows, envs, cwd)
if reconnect {
return c.PtyConnect(ctx, tag)
}
return c.PtyConnect(ctx, tag)
return c.PtyStart(ctx, tag, cmd, args, cols, rows, envs, cwd, user)
}
// PtySendInput sends raw bytes to a PTY process in a sandbox.

View File

@ -410,7 +410,15 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
templateDefaultUser := bctx.User
templateDefaultEnv := filterBuildEnv(bctx.EnvVars)
// Phase 3: Post-build (as root) — cleanup.
// Phase 3: Healthcheck — runs before cleanup so a failing healthcheck aborts
// immediately and skips post-build (the sandbox is destroyed regardless). It
// runs as the template's default user, against the app the recipe started.
if !s.runHealthcheck(buildCtx, buildID, build, agent, sandboxIDStr, &logs, streamFn, templateDefaultUser, log) {
return
}
// Phase 4: Post-build (as root) — cleanup. Only reached once the healthcheck
// has passed, so we never clean up an image that fails verification.
bctx.User = "root"
if !build.SkipPrePost {
if !runPhase("post-build", postBuildSteps, 0) {
@ -418,7 +426,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
}
}
// Finalize: healthcheck/snapshot/flatten → persist template → mark success.
// Finalize: snapshot/flatten → persist template → mark success.
s.finalizeBuild(buildCtx, buildID, build, agent, sandboxIDStr, templateDefaultUser, templateDefaultEnv, sandboxMetadata, log)
}
@ -517,8 +525,9 @@ func (s *BuildService) provisionBuildSandbox(
return agent, sandboxIDStr, sandboxMetadata, nil
}
// finalizeBuild handles the healthcheck/snapshot/flatten step and persists the
// template record. Called after all recipe phases complete successfully.
// finalizeBuild snapshots (or flattens) the verified rootfs and persists the
// template record. Called after the recipe, healthcheck, and post-build phases
// all complete successfully.
func (s *BuildService) finalizeBuild(
ctx context.Context,
buildID pgtype.UUID,
@ -532,23 +541,7 @@ func (s *BuildService) finalizeBuild(
) {
var sizeBytes int64
if build.Healthcheck != "" {
hc, err := recipe.ParseHealthcheck(build.Healthcheck)
if err != nil {
s.destroySandbox(ctx, agent, sandboxIDStr)
s.failBuild(ctx, buildID, fmt.Sprintf("invalid healthcheck: %v", err))
return
}
log.Info("running healthcheck", "cmd", hc.Cmd, "interval", hc.Interval, "timeout", hc.Timeout, "start_period", hc.StartPeriod, "retries", hc.Retries)
if err := s.waitForHealthcheck(ctx, agent, sandboxIDStr, hc, defaultUser); err != nil {
s.destroySandbox(ctx, agent, sandboxIDStr)
if ctx.Err() != nil {
return
}
s.failBuild(ctx, buildID, fmt.Sprintf("healthcheck failed: %v", err))
return
}
log.Info("healthcheck passed, creating snapshot")
log.Info("creating snapshot")
snapResp, err := agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{
SandboxId: sandboxIDStr,
Name: build.Name,
@ -630,19 +623,115 @@ func (s *BuildService) finalizeBuild(
log.Info("template build completed successfully", "name", build.Name)
}
// waitForHealthcheck repeatedly executes the healthcheck command inside the
// healthcheckResult captures what a healthcheck run produced, for recording as
// a build pseudo-step. Output is the merged per-attempt console log (capped to
// hcMaxOutputBytes); Exit is the final attempt's exit code.
type healthcheckResult struct {
Output string
Exit int32
Attempts int
Elapsed int64 // total wall time in milliseconds
}
// hcMaxOutputBytes bounds how much healthcheck output is retained for the cold
// log/replay, so a long-polling check can't grow the persisted log unbounded.
// The live stream is never truncated — this only caps what we store.
const hcMaxOutputBytes = 32 * 1024
// runHealthcheck executes the configured healthcheck (if any) as a streamed
// pseudo-step, before the post-build cleanup phase. It emits step-start /
// output / step-end events live, appends the resulting log entry to *logs, and
// persists it. The healthcheck is numbered after every recipe/post-build step
// (total_steps+1) and deliberately does not advance current_step.
//
// Returns true when the build should continue (healthcheck passed or none was
// configured), false when it must stop (invalid/failed healthcheck, or the
// build was cancelled) — in which case the sandbox has been destroyed and the
// build marked failed.
func (s *BuildService) runHealthcheck(
ctx context.Context,
buildID pgtype.UUID,
build db.TemplateBuild,
agent buildAgentClient,
sandboxIDStr string,
logs *[]recipe.BuildLogEntry,
streamFn recipe.StreamExecFunc,
user string,
log *slog.Logger,
) bool {
if build.Healthcheck == "" {
return true
}
hc, err := recipe.ParseHealthcheck(build.Healthcheck)
if err != nil {
s.destroySandbox(ctx, agent, sandboxIDStr)
s.failBuild(ctx, buildID, fmt.Sprintf("invalid healthcheck: %v", err))
return false
}
log.Info("running healthcheck", "cmd", hc.Cmd, "interval", hc.Interval, "timeout", hc.Timeout, "start_period", hc.StartPeriod, "retries", hc.Retries)
hcStep := int(build.TotalSteps) + 1
buildIDStr := id.FormatBuildID(buildID)
hcCmd := "HEALTHCHECK " + hc.Cmd
publishBuildEvent(ctx, s.Redis, buildIDStr, BuildStreamEvent{
Type: "step-start", Step: hcStep, Phase: "healthcheck", Cmd: hcCmd,
})
// Forward each output chunk to the live console as it arrives.
onChunk := func(data []byte) {
publishBuildEvent(ctx, s.Redis, buildIDStr, BuildStreamEvent{
Type: "output", Step: hcStep, Data: base64.StdEncoding.EncodeToString(data),
})
}
res, hcErr := s.streamHealthcheck(ctx, sandboxIDStr, hc, user, streamFn, onChunk)
entry := recipe.BuildLogEntry{
Step: hcStep, Phase: "healthcheck", Cmd: hcCmd,
Stdout: res.Output, Exit: res.Exit, Elapsed: res.Elapsed, Ok: hcErr == nil,
}
*logs = append(*logs, entry)
// Persist while pinning current_step to total_steps so the progress counter
// doesn't overshoot — the healthcheck is shown but not counted.
s.updateLogs(ctx, buildID, int(build.TotalSteps), *logs)
publishBuildEvent(ctx, s.Redis, buildIDStr, BuildStreamEvent{
Type: "step-end", Step: hcStep, Phase: "healthcheck", Cmd: hcCmd,
Exit: entry.Exit, Ok: entry.Ok, ElapsedMs: entry.Elapsed,
})
if hcErr != nil {
s.destroySandbox(ctx, agent, sandboxIDStr)
if ctx.Err() != nil {
return false
}
s.failBuild(ctx, buildID, fmt.Sprintf("healthcheck failed: %v", hcErr))
return false
}
log.Info("healthcheck passed")
return true
}
// streamHealthcheck repeatedly runs the healthcheck command in a PTY inside the
// sandbox according to the config's interval, timeout, start-period, and
// retries.
// During the start period, failures are not counted toward the retry budget.
// Returns nil on the first successful check, or an error if retries are
// exhausted, the deadline passes, or the context is cancelled.
func (s *BuildService) waitForHealthcheck(ctx context.Context, agent buildAgentClient, sandboxIDStr string, hc recipe.HealthcheckConfig, user string) error {
// retries, forwarding output live via onChunk. During the start period,
// failures are not counted toward the retry budget. Returns a nil error on the
// first successful check, or an error if retries are exhausted, the deadline
// passes, or the context is cancelled. The returned healthcheckResult always
// carries the captured per-attempt output regardless of outcome.
func (s *BuildService) streamHealthcheck(
ctx context.Context,
sandboxIDStr string,
hc recipe.HealthcheckConfig,
user string,
streamFn recipe.StreamExecFunc,
onChunk func(data []byte),
) (result healthcheckResult, err error) {
// Wrap the healthcheck command with su when a non-root user is set, so that
// ~ expands to the correct home directory and the process runs with the
// right UID (matching the template's default user).
cmd := hc.Cmd
if user != "" && user != "root" {
cmd = "su " + recipe.Shellescape(user) + " -s /bin/sh -c " + recipe.Shellescape(hc.Cmd)
// Drop `-s` so su runs the check under the user's login shell.
cmd = "su " + recipe.Shellescape(user) + " -c " + recipe.Shellescape(hc.Cmd)
}
ticker := time.NewTicker(hc.Interval)
defer ticker.Stop()
@ -658,41 +747,82 @@ func (s *BuildService) waitForHealthcheck(ctx context.Context, agent buildAgentC
startedAt := time.Now()
failCount := 0
attempt := 0
var output strings.Builder
// emit sends a line to the live console and accumulates it (capped) for the
// cold log used on reconnect/replay.
emit := func(line string) {
onChunk([]byte(line))
if output.Len() < hcMaxOutputBytes {
output.WriteString(line)
}
}
// Populate the result on every return path.
defer func() {
result.Attempts = attempt
result.Elapsed = time.Since(startedAt).Milliseconds()
result.Output = strings.TrimRight(output.String(), "\r\n")
}()
// runAttempt streams one healthcheck invocation, forwarding output, and
// returns its exit code (or an error if the exec never completed).
runAttempt := func() (int32, error) {
attemptCtx, cancel := context.WithTimeout(ctx, hc.Timeout)
defer cancel()
ch, err := streamFn(attemptCtx, sandboxIDStr, cmd)
if err != nil {
return -1, err
}
var exit int32
gotDone := false
for chunk := range ch {
if chunk.Err != nil {
return -1, chunk.Err
}
if chunk.Done {
exit, gotDone = chunk.Exit, true
continue
}
emit(string(chunk.Data))
}
if !gotDone {
return -1, fmt.Errorf("healthcheck stream ended without completion")
}
return exit, nil
}
for {
select {
case <-ctx.Done():
return ctx.Err()
return result, ctx.Err()
case <-deadlineCh:
return fmt.Errorf("healthcheck timed out: exceeded %d attempts over %s", failCount, time.Since(startedAt))
return result, fmt.Errorf("healthcheck timed out: exceeded %d attempts over %s", failCount, time.Since(startedAt))
case <-ticker.C:
execCtx, cancel := context.WithTimeout(ctx, hc.Timeout)
resp, err := agent.Exec(execCtx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxIDStr,
Cmd: "/bin/sh",
Args: []string{"-c", cmd},
TimeoutSec: int32(hc.Timeout.Seconds()),
}))
cancel()
attempt++
emit(fmt.Sprintf("\x1b[2m── attempt %d ──\x1b[0m\r\n", attempt))
exit, attemptErr := runAttempt()
result.Exit = exit
if err != nil {
slog.Debug("healthcheck exec error (retrying)", "error", err)
if attemptErr != nil {
emit(fmt.Sprintf("exec error: %v\r\n", attemptErr))
slog.Debug("healthcheck exec error (retrying)", "error", attemptErr)
if time.Since(startedAt) >= hc.StartPeriod {
failCount++
if hc.Retries > 0 && failCount >= hc.Retries {
return fmt.Errorf("healthcheck failed after %d retries: exec error: %w", failCount, err)
return result, fmt.Errorf("healthcheck failed after %d retries: exec error: %w", failCount, attemptErr)
}
}
continue
}
if resp.Msg.ExitCode == 0 {
return nil
emit(fmt.Sprintf("\x1b[2m→ exit %d\x1b[0m\r\n", exit))
if exit == 0 {
return result, nil
}
slog.Debug("healthcheck failed (retrying)", "exit_code", resp.Msg.ExitCode)
slog.Debug("healthcheck failed (retrying)", "exit_code", exit)
if time.Since(startedAt) >= hc.StartPeriod {
failCount++
if hc.Retries > 0 && failCount >= hc.Retries {
return fmt.Errorf("healthcheck failed after %d retries: exit code %d", failCount, resp.Msg.ExitCode)
return result, fmt.Errorf("healthcheck failed after %d retries: exit code %d", failCount, exit)
}
}
}
@ -755,10 +885,12 @@ func (s *BuildService) ptyStreamExec(agent buildAgentClient) recipe.StreamExecFu
stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{
SandboxId: sandboxID,
Tag: tag,
Cmd: "/bin/sh",
Args: []string{"-c", shellCmd},
Cols: buildPtyCols,
Rows: buildPtyRows,
// Bare command: envd wraps it in the user's login shell (resolved
// from /etc/passwd), so build steps run under the image's default
// shell instead of a forced /bin/sh.
Cmd: shellCmd,
Cols: buildPtyCols,
Rows: buildPtyRows,
}))
if err != nil {
return nil, err
@ -836,8 +968,7 @@ func (s *BuildService) fetchSandboxEnv(ctx context.Context,
agent buildAgentClient, sandboxIDStr string) (map[string]string, error) {
resp, err := agent.Exec(ctx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxIDStr,
Cmd: "/bin/sh",
Args: []string{"-c", "env"},
Cmd: "env",
TimeoutSec: 10,
}))
if err != nil {
@ -922,8 +1053,7 @@ func (s *BuildService) uploadAndExtractArchive(
resp, err := agent.Exec(ctx, connect.NewRequest(&pb.ExecRequest{
SandboxId: sandboxID,
Cmd: "/bin/sh",
Args: []string{"-c", fullCmd},
Cmd: fullCmd,
TimeoutSec: 120,
}))
if err != nil {

View File

@ -117,11 +117,13 @@ func (x *PTY) GetSize() *PTY_Size {
}
type ProcessConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"`
Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"`
Envs map[string]string `protobuf:"bytes,3,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
Cwd *string `protobuf:"bytes,4,opt,name=cwd,proto3,oneof" json:"cwd,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"`
Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"`
Envs map[string]string `protobuf:"bytes,3,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
Cwd *string `protobuf:"bytes,4,opt,name=cwd,proto3,oneof" json:"cwd,omitempty"`
// User to run the process as. Empty means the sandbox default user.
User string `protobuf:"bytes,5,opt,name=user,proto3" json:"user,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -184,6 +186,13 @@ func (x *ProcessConfig) GetCwd() string {
return ""
}
func (x *ProcessConfig) GetUser() string {
if x != nil {
return x.User
}
return ""
}
type ListRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@ -1713,12 +1722,13 @@ const file_process_proto_rawDesc = "" +
"\x04size\x18\x01 \x01(\v2\x11.process.PTY.SizeR\x04size\x1a.\n" +
"\x04Size\x12\x12\n" +
"\x04cols\x18\x01 \x01(\rR\x04cols\x12\x12\n" +
"\x04rows\x18\x02 \x01(\rR\x04rows\"\xc3\x01\n" +
"\x04rows\x18\x02 \x01(\rR\x04rows\"\xd7\x01\n" +
"\rProcessConfig\x12\x10\n" +
"\x03cmd\x18\x01 \x01(\tR\x03cmd\x12\x12\n" +
"\x04args\x18\x02 \x03(\tR\x04args\x124\n" +
"\x04envs\x18\x03 \x03(\v2 .process.ProcessConfig.EnvsEntryR\x04envs\x12\x15\n" +
"\x03cwd\x18\x04 \x01(\tH\x00R\x03cwd\x88\x01\x01\x1a7\n" +
"\x03cwd\x18\x04 \x01(\tH\x00R\x03cwd\x88\x01\x01\x12\x12\n" +
"\x04user\x18\x05 \x01(\tR\x04user\x1a7\n" +
"\tEnvsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" +

View File

@ -37,6 +37,9 @@ message ProcessConfig {
map<string, string> envs = 3;
optional string cwd = 4;
// User to run the process as. Empty means the sandbox default user.
string user = 5;
}
message ListRequest {}

View File

@ -2985,8 +2985,8 @@ type PtyAttachRequest struct {
// Tag is the stable identifier for this PTY session (e.g. "pty-abc123de").
// Chosen by the caller and used to reconnect later.
Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"`
// If cmd is non-empty, a new process is started. If empty, reconnects to
// the existing process identified by tag.
// Command to run for a new session. May be empty to launch the user's
// default login shell. Ignored when reconnect is true.
Cmd string `protobuf:"bytes,3,opt,name=cmd,proto3" json:"cmd,omitempty"`
Args []string `protobuf:"bytes,4,rep,name=args,proto3" json:"args,omitempty"`
Cols uint32 `protobuf:"varint,5,opt,name=cols,proto3" json:"cols,omitempty"`
@ -2996,7 +2996,11 @@ type PtyAttachRequest struct {
// Working directory. Empty means default.
Cwd string `protobuf:"bytes,8,opt,name=cwd,proto3" json:"cwd,omitempty"`
// User to run as. Empty means default (root).
User string `protobuf:"bytes,9,opt,name=user,proto3" json:"user,omitempty"`
User string `protobuf:"bytes,9,opt,name=user,proto3" json:"user,omitempty"`
// If true, reconnect to the existing process identified by tag instead of
// starting a new one. Distinguishes reconnect from a start whose cmd is
// empty (login-shell default).
Reconnect bool `protobuf:"varint,10,opt,name=reconnect,proto3" json:"reconnect,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -3094,6 +3098,13 @@ func (x *PtyAttachRequest) GetUser() string {
return ""
}
func (x *PtyAttachRequest) GetReconnect() bool {
if x != nil {
return x.Reconnect
}
return false
}
type PtyAttachResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Event:
@ -4490,7 +4501,7 @@ const file_hostagent_proto_rawDesc = "" +
"templateId\"8\n" +
"\x17GetTemplateSizeResponse\x12\x1d\n" +
"\n" +
"size_bytes\x18\x01 \x01(\x03R\tsizeBytes\"\xae\x02\n" +
"size_bytes\x18\x01 \x01(\x03R\tsizeBytes\"\xcc\x02\n" +
"\x10PtyAttachRequest\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" +
@ -4501,7 +4512,9 @@ const file_hostagent_proto_rawDesc = "" +
"\x04rows\x18\x06 \x01(\rR\x04rows\x12<\n" +
"\x04envs\x18\a \x03(\v2(.hostagent.v1.PtyAttachRequest.EnvsEntryR\x04envs\x12\x10\n" +
"\x03cwd\x18\b \x01(\tR\x03cwd\x12\x12\n" +
"\x04user\x18\t \x01(\tR\x04user\x1a7\n" +
"\x04user\x18\t \x01(\tR\x04user\x12\x1c\n" +
"\treconnect\x18\n" +
" \x01(\bR\treconnect\x1a7\n" +
"\tEnvsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb8\x01\n" +

View File

@ -471,8 +471,8 @@ message PtyAttachRequest {
// Tag is the stable identifier for this PTY session (e.g. "pty-abc123de").
// Chosen by the caller and used to reconnect later.
string tag = 2;
// If cmd is non-empty, a new process is started. If empty, reconnects to
// the existing process identified by tag.
// Command to run for a new session. May be empty to launch the user's
// default login shell. Ignored when reconnect is true.
string cmd = 3;
repeated string args = 4;
uint32 cols = 5;
@ -483,6 +483,10 @@ message PtyAttachRequest {
string cwd = 8;
// User to run as. Empty means default (root).
string user = 9;
// If true, reconnect to the existing process identified by tag instead of
// starting a new one. Distinguishes reconnect from a start whose cmd is
// empty (login-shell default).
bool reconnect = 10;
}
message PtyAttachResponse {

View File

@ -1,169 +0,0 @@
#!/usr/bin/env python3
import argparse
import json
import sys
import urllib.request
import uuid
try:
import websocket
except ImportError:
print("websocket-client is required: pip install websocket-client")
sys.exit(1)
def create_kernel(base_url: str, token: str) -> str:
url = f"{base_url}/api/kernels"
headers = {}
if token:
headers["X-API-Key"] = token
req = urllib.request.Request(url, method="POST", data=b"", headers=headers)
resp = urllib.request.urlopen(req)
data = json.loads(resp.read())
kernel_id = data["id"]
print(f"Created kernel: {kernel_id}")
return kernel_id
def execute_code(ws: websocket.WebSocket, code: str) -> dict:
msg_id = str(uuid.uuid4())
session_id = str(uuid.uuid4())
msg = {
"header": {
"msg_type": "execute_request",
"msg_id": msg_id,
"username": "",
"session": session_id,
"version": "5.3",
"date": "",
},
"parent_header": {},
"metadata": {},
"content": {
"code": code,
"silent": False,
"store_history": True,
"user_expressions": {},
},
"buffers": [],
"channel": "shell",
}
ws.send(json.dumps(msg))
result = {"stdout": "", "stderr": "", "output": None, "error": None}
while True:
resp = json.loads(ws.recv())
# Filter out messages from other executions by matching msg_id
parent_id = resp.get("parent_header", {}).get("msg_id")
if parent_id != msg_id:
continue
msg_type = resp.get("msg_type", "")
if msg_type == "stream":
result["stdout"] += resp["content"]["text"]
elif msg_type == "error":
result["error"] = "\n".join(resp["content"].get("traceback", []))
elif msg_type == "execute_result":
result["output"] = resp["content"]["data"]
elif msg_type == "status":
if resp["content"]["execution_state"] == "idle":
break
return result
def main():
parser = argparse.ArgumentParser(
description="Test Jupyter kernel state management in a sandbox"
)
parser.add_argument(
"sandbox_id",
help="Sandbox ID (e.g. cl-8nxizn9ygtczplsnn9jve38be)",
)
parser.add_argument(
"--domain",
default="localhost:8080",
help="Proxy domain (default: localhost:8080)",
)
parser.add_argument(
"--port",
default="8888",
help="Jupyter port inside the sandbox (default: 8888)",
)
parser.add_argument(
"--key",
default="",
help="Wrenn API Token",
)
args = parser.parse_args()
base_url = f"http://{args.port}-{args.sandbox_id}.{args.domain}"
ws_base = base_url.replace("http", "ws", 1)
print(f"Testing Jupyter kernel at {base_url}")
print()
kernel_id = create_kernel(base_url, args.key)
ws_url = f"{ws_base}/api/kernels/{kernel_id}/channels"
# Pass auth headers to the WebSocket if a token was provided
ws_headers = {}
if args.key:
ws_headers["X-API-Key"] = args.key
ws = websocket.create_connection(ws_url, header=ws_headers)
print("Connected to kernel WebSocket")
print()
tests = [
("variable assignment", "x = 42", None),
("read variable", "x * 2", "84"),
("import", "import math", None),
("use import", "math.sqrt(144)", "12.0"),
("function definition", "def greet(name): return f'hello {name}'", None),
# Fixed: Jupyter 'execute_result' strings include the literal single quotes
("call function", "greet('sandbox')", "'hello sandbox'"),
("list mutation", "items = [1, 2, 3]; items.append(4); items", "[1, 2, 3, 4]"),
]
passed = 0
failed = 0
for name, code, expected in tests:
print(f" {name}: {code}")
result = execute_code(ws, code)
if result["error"]:
print(f" ERROR: {result['error']}")
failed += 1
continue
output = result["stdout"].strip()
if not output and result["output"]:
if "text/plain" in result["output"]:
output = result["output"]["text/plain"].strip()
if expected is not None:
if output == expected:
print(f" PASS (got: {output})")
passed += 1
else:
print(f" FAIL (expected: {expected}, got: {output})")
failed += 1
else:
print(" OK")
passed += 1
ws.close()
print()
print(f"Results: {passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()