diff --git a/Cargo.lock b/Cargo.lock index 7297307..7d50be6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,8 +219,24 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f6fd5ddaf0351dff5b8da21b2fb4ff8e08ddd02857f0bf69c47639106c0fff0" dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", + "asn1-rs-derive 0.4.0", + "asn1-rs-impl 0.1.0", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl 0.2.0", "displaydoc", "nom", "num-traits", @@ -241,6 +257,18 @@ dependencies = [ "synstructure 0.12.6", ] +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + [[package]] name = "asn1-rs-impl" version = "0.1.0" @@ -252,6 +280,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ast_node" version = "5.0.0" @@ -275,6 +314,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -346,6 +407,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -368,6 +435,28 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "base64urlsafedata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + [[package]] name = "better_scoped_tls" version = "1.0.1" @@ -383,7 +472,7 @@ version = "0.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools 0.13.0", @@ -403,7 +492,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools 0.13.0", @@ -451,6 +540,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -505,6 +600,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "borsh" version = "1.8.1" @@ -590,6 +695,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -729,6 +840,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -740,6 +862,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -758,6 +893,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", "inout", + "zeroize", ] [[package]] @@ -1268,6 +1404,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -1313,7 +1450,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f7c1384d87fc0a6439a065312fbef8f6ac6128689dbc2831b28b3a1d4f3a4e6" dependencies = [ - "base64", + "base64 0.22.1", "capacity_builder", "deno_error", "deno_media_type", @@ -1356,7 +1493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ad7d5294f640fd99db08c455e2453c9860b1fa728b76fbdacf0eed3e308d48a" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bit-set 0.8.0", "bit-vec 0.8.0", "boxed_error", @@ -1401,7 +1538,7 @@ dependencies = [ "aes-kw", "argon2", "aws-lc-rs", - "base64", + "base64 0.22.1", "cbc", "const-oid 0.9.6", "ctr", @@ -1488,7 +1625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "752387783c99f75ea6962bb88af3735baba9f4a9e66716eacd39586db0e28d99" dependencies = [ "async-compression", - "base64", + "base64 0.22.1", "bytes", "data-url", "deno_core", @@ -1632,7 +1769,7 @@ dependencies = [ "tokio-vsock", "url", "web-transport-proto", - "x509-parser", + "x509-parser 0.15.1", ] [[package]] @@ -1882,7 +2019,21 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" dependencies = [ - "asn1-rs", + "asn1-rs 0.5.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", "displaydoc", "nom", "num-bigint", @@ -2076,6 +2227,30 @@ dependencies = [ "spki 0.7.3", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "ed448" version = "0.5.0" @@ -2331,6 +2506,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2483,7 +2673,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" dependencies = [ - "bitflags", + "bitflags 2.13.2", "debugid", "rustc-hash 2.1.3", "serde", @@ -2619,6 +2809,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash2curve" version = "0.14.0" @@ -2680,6 +2881,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hickory-proto" version = "0.25.2" @@ -3437,7 +3644,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -3450,7 +3657,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -3500,6 +3707,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-integer" version = "0.1.47" @@ -3578,7 +3796,16 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bedf36ffb6ba96c2eb7144ef6270557b52e54b20c0a8e1eb2ff99a6c6959bff" dependencies = [ - "asn1-rs", + "asn1-rs 0.5.2", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", ] [[package]] @@ -3603,12 +3830,59 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de8111e94a56adca7ebfe6c365b6d42f99deb69b74b6becacd4204e2f193aa2" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c413d6ec9ebaf5e1c829d355cb3e9f783778d700f135be0921d52fce3c60ae" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.32.0" @@ -3657,7 +3931,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ - "base64", + "base64 0.22.1", "const-hex", "opentelemetry", "opentelemetry_sdk", @@ -3790,12 +4064,27 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3979,6 +4268,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -4080,8 +4380,8 @@ checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ "bit-set 0.11.1", "bit-vec 0.10.1", - "bitflags", - "chacha20", + "bitflags 2.13.2", + "chacha20 0.10.2", "core_detect", "num-traits", "rand 0.10.3", @@ -4266,7 +4566,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4359,7 +4659,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -4471,6 +4771,7 @@ dependencies = [ "pkcs1", "pkcs8 0.10.2", "rand_core 0.6.4", + "sha2", "signature 2.2.0", "spki 0.7.3", "subtle", @@ -4493,7 +4794,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4544,7 +4845,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4557,7 +4858,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -4690,8 +4991,11 @@ dependencies = [ name = "sausage" version = "0.3.0" dependencies = [ + "aes-gcm", "anyhow", "async-trait", + "base64 0.22.1", + "chacha20poly1305", "clap", "deno_ast", "deno_core", @@ -4703,19 +5007,35 @@ dependencies = [ "deno_telemetry", "deno_web", "deno_webidl", + "ed25519-dalek", + "hex", + "hkdf", + "hmac", + "http", + "openssl", + "p256", + "rsa", "rusqlite", "rustls", "sausage-vfs", "serde", "serde_json", "serde_yaml_ng", + "sha2", + "ssh-key", "sys_traits", "tempfile", + "thiserror 2.0.21", "tokio", + "url", + "uuid", "wasmtime", "wasmtime-wasi", "wasmtime-wasi-http", "wat", + "webauthn-authenticator-rs", + "webauthn-rs", + "zeroize", ] [[package]] @@ -4793,7 +5113,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -4846,6 +5166,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -5158,6 +5488,57 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20 0.9.1", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1 0.7.3", + "sha2", + "signature 2.2.0", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5324,7 +5705,7 @@ version = "18.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a573a0c72850dec8d4d8085f152d5778af35a2520c3093b242d2d1d50776da7c" dependencies = [ - "bitflags", + "bitflags 2.13.2", "is-macro", "num-bigint", "once_cell", @@ -5379,7 +5760,7 @@ version = "26.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e82f7747e052c6ff6e111fa4adeb14e33b46ee6e94fe5ef717601f651db48fc" dependencies = [ - "bitflags", + "bitflags 2.13.2", "either", "num-bigint", "rustc-hash 2.1.3", @@ -5416,7 +5797,7 @@ version = "27.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f1a51af1a92cd4904c073b293e491bbc0918400a45d58227b34c961dd6f52d7" dependencies = [ - "bitflags", + "bitflags 2.13.2", "either", "num-bigint", "phf 0.11.3", @@ -5502,7 +5883,7 @@ version = "33.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03de12e38e47ac1c96ac576f793ad37a9d7b16fbf4f2203881f89152f2498682" dependencies = [ - "base64", + "base64 0.22.1", "bytes-str", "indexmap", "once_cell", @@ -5685,6 +6066,17 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -5967,6 +6359,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -6234,9 +6627,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.28.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -6251,7 +6644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42a978ff11f15b24e5c05a7123cf2b68f41e763546699781a924ef4e2cf43a49" dependencies = [ "bindgen 0.72.1", - "bitflags", + "bitflags 2.13.2", "fslock", "gzip-header", "home", @@ -6268,7 +6661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3701ffb3692a24808503d17f758b0a3f688d2c451779247982566b5941033e63" dependencies = [ "bindgen 0.70.1", - "bitflags", + "bitflags 2.13.2", "cc", "paste", "temporal_capi", @@ -6447,7 +6840,7 @@ version = "0.258.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" dependencies = [ - "bitflags", + "bitflags 2.13.2", "hashbrown 0.17.1", "indexmap", "semver", @@ -6460,7 +6853,7 @@ version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" dependencies = [ - "bitflags", + "bitflags 2.13.2", "indexmap", "semver", ] @@ -6484,7 +6877,7 @@ checksum = "4fbdd39f5c66f63ba6b5b31391b324b7ade7237d29c866cdb561330118ef436e" dependencies = [ "addr2line", "async-trait", - "bitflags", + "bitflags 2.13.2", "bumpalo", "bytes", "cc", @@ -6566,7 +6959,7 @@ version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "952086551d2041430a159fe4bda3471cfea3d52846b93c825829cea9dd01954d" dependencies = [ - "base64", + "base64 0.22.1", "directories-next", "log", "postcard", @@ -6706,7 +7099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cad5e8cef56790d4741b62f7b6c971af53b2ad4c2d1603f8ffe86872763147aa" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.2", "heck", "indexmap", "wit-component", @@ -6720,7 +7113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "284555904d42017e049fd57c05810b7975075c322481c045f99b07385fb7a5ba" dependencies = [ "async-trait", - "bitflags", + "bitflags 2.13.2", "bytes", "cap-primitives", "futures", @@ -6829,6 +7222,107 @@ dependencies = [ "url", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-authenticator-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779e9c80ff248c7e12ea967f909249101f6e86f70fccd742d4b66c490c1710b4" +dependencies = [ + "async-stream", + "async-trait", + "base64 0.21.7", + "base64urlsafedata", + "bitflags 1.3.2", + "futures", + "hex", + "nom", + "num-derive", + "num-traits", + "openssl", + "openssl-sys", + "serde", + "serde_bytes", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "unicode-normalization", + "url", + "uuid", + "webauthn-rs-core", + "webauthn-rs-proto", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser 9.0.0", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.9.5", + "rand_chacha 0.9.0", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser 0.16.0", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-roots" version = "1.0.11" @@ -6868,7 +7362,7 @@ version = "49.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad3dcdc2980b91d0e7db6975caee4f553c6e37fd8e1464a38b09baceed3fe568" dependencies = [ - "bitflags", + "bitflags 2.13.2", "thiserror 2.0.21", "tracing", "wasmtime", @@ -7155,7 +7649,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "windows-sys 0.59.0", ] @@ -7165,7 +7659,7 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -7175,7 +7669,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fca88950af0c1f42bc77e17315e48798b8d68340171b6d204f62bc19a878a6" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.13.2", "indexmap", "log", "serde", @@ -7251,12 +7745,29 @@ version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7069fba5b66b9193bd2c5d3d4ff12b839118f6bcbef5328efafafb5395cf63da" dependencies = [ - "asn1-rs", + "asn1-rs 0.5.2", "data-encoding", - "der-parser", + "der-parser 8.2.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.6.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", "rusticata-macros", "thiserror 1.0.69", "time", diff --git a/host/Cargo.toml b/host/Cargo.toml index 1c7244e..88863a7 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -33,7 +33,26 @@ wasmtime-wasi-http = "=49.0.3" serde = { version = "1.0.229", features = ["derive"] } serde_yaml_ng = "0.10.0" rustls = "0.23" +chacha20poly1305 = "0.10" +zeroize = "1" +hex = "0.4" +http = "1" +url = "2" +thiserror = "2" +base64 = "0.22" +hmac = "0.12" +sha2 = "0.10" +p256 = { version = "0.13", features = ["ecdh", "ecdsa", "pem"] } +ed25519-dalek = { version = "2", features = ["pkcs8", "pem"] } +rsa = { version = "0.9", features = ["sha2"] } +aes-gcm = "0.10" +hkdf = "0.12" +ssh-key = { version = "0.6.7", features = ["encryption", "ed25519", "ecdsa", "rsa", "p256"] } +uuid = { version = "1.29.0", features = ["v4"] } +webauthn-rs = "0.5" +openssl = { version = "0.10.82", features = ["vendored"] } [dev-dependencies] tempfile = "3" wat = "1" +webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] } diff --git a/host/js/README.md b/host/js/README.md index 263dd74..e09cf5e 100644 --- a/host/js/README.md +++ b/host/js/README.md @@ -35,6 +35,20 @@ For Nix, use a separate npm derivation with `importNpmLock` and the checked-in l Run the bundle script there. Pass its output to the Rust source derivation before Crane builds the host. The Rust build must not execute npm or download JavaScript packages. -The console reads `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` from the host environment. -It selects `openai/gpt-6.1-sol` with low reasoning effort. +The console reads the `llm-proxy` credentials item through Rust. +Its public `base_url` field contains the proxy URL. Its secret `api_key` field contains the token. +If the item does not exist, Rust creates it from `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` once. +Rust encrypts the token before it commits the item. Later starts ignore these environment variables. +The token enters JavaScript only through an operation. No startup script, transcript, or world file stores it. +The console selects `openai/gpt-6.1-sol` with low reasoning effort. This model rejects disabled reasoning. Tests use the faux provider without credentials. + +Credential requests own background pi-durable tasks in their requesting conversations. +Filed notifications create tasks. Startup reconciliation also creates missing tasks, including requests decided during a host stop. +A conversation document records each request id in the same commit that creates its task. +Program requests create no task. A pending task sleeps durably and checks the decision each second. +The harness also expires requests during these checks. +A decided task admits one plain-language input and completes in one SQLite commit. +The input starts a new turn when the conversation is idle. Neither the input nor the task contains a secret. +The task and its input survive SIGKILL. The input uses a stable request id to prevent duplicate admission. +Hosts with a shared credentials handle can use `run_module_with_credentials`. diff --git a/host/js/bundle.js b/host/js/bundle.js index 490ae91..c168ae9 100644 --- a/host/js/bundle.js +++ b/host/js/bundle.js @@ -33805,9 +33805,9 @@ async function createAgent(tx, conversation) { function agentHooks(agent, taskName) { const handlers = []; for (const extension of agent.extensions) { - for (const hook of extension.hooks ?? []) { - if (hook.task === taskName) - handlers.push({ extension: extension.name, handlers: hook.handlers }); + for (const hook2 of extension.hooks ?? []) { + if (hook2.task === taskName) + handlers.push({ extension: extension.name, handlers: hook2.handlers }); } } return handlers; @@ -33815,7 +33815,7 @@ function agentHooks(agent, taskName) { function sectionExtension(agent, key) { let found; for (const extension of agent.extensions) { - const provides = extension.sections?.some((section) => section.key === key) === true; + const provides = extension.sections?.some((section2) => section2.key === key) === true; const wraps = extension.wraps?.some((wrap) => "section" in wrap && wrap.section === key) === true; if (provides || wraps) found = extension.name; @@ -33840,8 +33840,8 @@ function resolveAgent(state2, snapshot, settings, report2) { composed.set(tool.name, tool); const sections = /* @__PURE__ */ new Map(); for (const extension of extensions) { - for (const section of extension.sections ?? []) - sections.set(section.key, section); + for (const section2 of extension.sections ?? []) + sections.set(section2.key, section2); } for (const extension of extensions) { for (const wrap of extension.wraps ?? []) { @@ -33849,7 +33849,7 @@ function resolveAgent(state2, snapshot, settings, report2) { if ("tool" in wrap) applyWrap(composed, wrap.tool, (tool) => wrap.wrap(tool), (tool) => tool.name, wrapReport); else - applyWrap(sections, wrap.section, (section) => wrap.wrap(section), (section) => section.key, wrapReport); + applyWrap(sections, wrap.section, (section2) => wrap.wrap(section2), (section2) => section2.key, wrapReport); } } const enabled = filterTools([...composed.values()], state2?.tools); @@ -34685,24 +34685,24 @@ function replaySections(messages) { } async function renderSections(sections, input, shown, report2, context) { const desired = /* @__PURE__ */ new Map(); - for (const section of sections) { + for (const section2 of sections) { let text2; try { - text2 = await section.render(input, context); + text2 = await section2.render(input, context); } catch (error) { if (context.abortSignal?.aborted) throw error; - report2(error, section); - const kept = shown.get(section.key); + report2(error, section2); + const kept = shown.get(section2.key); if (kept !== void 0) - desired.set(section.key, kept); + desired.set(section2.key, kept); continue; } if (text2 === void 0) continue; - desired.set(section.key, section.tag === false ? text2 : `<${section.key}> + desired.set(section2.key, section2.tag === false ? text2 : `<${section2.key}> ${text2} -`); +`); } return desired; } @@ -38706,11 +38706,11 @@ var ToolTask = defineTask({ return reject(runtime, task.input, call, tool, invalid(checked.error), context); let args = checked.args; let block; - await runtime.hooks.each("beforeTool", async (hook) => { + await runtime.hooks.each("beforeTool", async (hook2) => { if (block !== void 0) return; try { - const decision = await hook({ ...call, arguments: args }, runtime, context); + const decision = await hook2({ ...call, arguments: args }, runtime, context); if (decision?.block !== void 0) block = decision.block; else if (decision?.arguments !== void 0) @@ -39052,8 +39052,8 @@ async function finalResult(runtime, input, call, tool, result, reported, context details: result.details === void 0 ? reported.details : result.details, diagnostics: [...reported.diagnostics, ...result.diagnostics ?? []] }; - await runtime.hooks.each("afterTool", async (hook) => { - final = await hook(call, final, runtime, context) ?? final; + await runtime.hooks.each("afterTool", async (hook2) => { + final = await hook2(call, final, runtime, context) ?? final; }); if (retained !== void 0 && retained.droppedBytes > 0 && final.output === output) { harness2.push(truncated(retained, reported.limits.retain)); @@ -39085,8 +39085,8 @@ async function finalResult(runtime, input, call, tool, result, reported, context } async function afterHarnessResult(runtime, input, call, tool, result, truncation, context) { let final = result; - await runtime.hooks.each("afterTool", async (hook) => { - final = await hook(call, final, runtime, context) ?? final; + await runtime.hooks.each("afterTool", async (hook2) => { + final = await hook2(call, final, runtime, context) ?? final; }); const harness2 = []; if (truncation !== void 0 && final.output === result.output) @@ -39406,9 +39406,9 @@ var GenerationTask = defineTask({ runtime.report(error, { kind: "host", callback: "env" }); } const input = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime }; - const desired = await renderSections(agent.sections, input, shown, (error, section) => runtime.report(error, { + const desired = await renderSections(agent.sections, input, shown, (error, section2) => runtime.report(error, { kind: "extension", - ...optionalExtension(sectionExtension(agent, section.key)) + ...optionalExtension(sectionExtension(agent, section2.key)) }), context); const entries2 = planSystemEntries(view2, desired, agent.tools, runtime.now()); const threshold = compacted === void 0 ? thresholdCompaction(view2, entries2, resolved.contextWindow, compactionPolicy(settings, model3)) : void 0; @@ -39456,8 +39456,8 @@ var GenerationTask = defineTask({ return failNoModel(runtime, ref, context); const view2 = await runtime.context(conversationId, context, { at: cutoff }); let messages = view2.messages; - await runtime.hooks.each("beforeRequest", async (hook) => { - const replaced = await hook({ messages }, runtime, context); + await runtime.hooks.each("beforeRequest", async (hook2) => { + const replaced = await hook2({ messages }, runtime, context); if (replaced !== void 0) messages = replaced.messages; }); @@ -39545,8 +39545,8 @@ var GenerationTask = defineTask({ if (cutOffTooOften(task, runtime)) return endAnsweredCutOff(runtime, task, run2.answer, context); const prepared = []; - await runtime.hooks.each("afterRun", async (hook) => { - const asked = await hook(run2, runtime, context); + await runtime.hooks.each("afterRun", async (hook2) => { + const asked = await hook2(run2, runtime, context); if (asked !== void 0) prepareRunEndTasks(runtime, asked.tasks, prepared); }); @@ -39754,8 +39754,8 @@ async function convertPartial(tx, live, conversationId) { } async function providerPayload(runtime, model3, payload, context) { let current = payload; - await runtime.hooks.each("beforeProviderRequest", async (hook) => { - const replaced = await hook({ model: model3, payload: current }, runtime, context); + await runtime.hooks.each("beforeProviderRequest", async (hook2) => { + const replaced = await hook2({ model: model3, payload: current }, runtime, context); if (replaced !== void 0) current = replaced.payload; }); @@ -39839,7 +39839,7 @@ async function classify2(runtime, request2, message, context) { }, context); return; } - await runtime.hooks.each("afterResponse", (hook) => hook(message, runtime, context)); + await runtime.hooks.each("afterResponse", (hook2) => hook2(message, runtime, context)); const calls = message.content.filter((content) => content.type === "toolCall"); if (message.stopReason === "toolUse" && calls.length > 0) { return startToolRound(runtime, request2, message, calls, context); @@ -39892,10 +39892,10 @@ async function classify2(runtime, request2, message, context) { } async function answer(runtime, message, context) { let continuation; - await runtime.hooks.each("onYield", async (hook) => { + await runtime.hooks.each("onYield", async (hook2) => { if (continuation !== void 0) return; - continuation = (await hook(message, runtime, context))?.continue; + continuation = (await hook2(message, runtime, context))?.continue; }); const afterRun = await asksAfterRun(runtime, context); const conversationId = runtime.conversationId; @@ -40038,7 +40038,7 @@ async function finishToolRound(runtime, assistant, tools, context) { }); const slots = (await runtime.snapshot(LiveDoc, conversationId, context))?.tools ?? []; const results = slots.flatMap((slot) => slot.entry === void 0 ? [] : [slot.entry]); - await runtime.hooks.each("afterTools", (hook) => hook(assistant, results, runtime, context)); + await runtime.hooks.each("afterTools", (hook2) => hook2(assistant, results, runtime, context)); const ordered = slots.map((slot) => slot.taskId === void 0 ? void 0 : controls.get(slot.taskId)); const terminate = slots.length > 0 && ordered.every((control) => control?.terminate === true); const added = ordered.flatMap((control) => control?.addTools ?? []); @@ -40583,9 +40583,9 @@ var CompactionTask = defineTask({ ...instructions === void 0 ? {} : { instructions } }; let decision; - await runtime.hooks.each("beforeCompact", async (hook) => { + await runtime.hooks.each("beforeCompact", async (hook2) => { if (decision === void 0) - decision = await hook(compaction, runtime, context); + decision = await hook2(compaction, runtime, context); }); if (decision !== void 0 && "decline" in decision) return complete(runtime, context); @@ -40919,7 +40919,7 @@ var RegistryState = class { return this.#extensions.flatMap((extension) => (extension.tools ?? []).map((tool) => ({ extension, tool }))); } sections() { - return this.#extensions.flatMap((extension) => (extension.sections ?? []).map((section) => ({ extension, section }))); + return this.#extensions.flatMap((extension) => (extension.sections ?? []).map((section2) => ({ extension, section: section2 }))); } tasks() { return [...this.#tasks.values()]; @@ -44424,10 +44424,67 @@ function environment(cwd = "/", conversation) { return env; } -// main.ts -if (!process.env.PI_PROXY_BASE_URL || !process.env.PI_PROXY_API_KEY) { - throw new Error("PI_PROXY_BASE_URL and PI_PROXY_API_KEY are required"); +// credentials.ts +var Requests = defineDoc({ + kind: "sausage.credential.requests", + version: 1, + scope: "conversation", + history: "latest", + fork: "initial", + initial: () => ({ ids: [] }), + checkpointWhen: () => false +}); +var Wait = defineTask({ + name: "sausage.credential.wait", + version: 1, + initial: () => ({ phase: "wait" }), + phases: { + wait: async (task, runtime, context) => { + const request2 = Deno.core.ops.op_credential_requests().find((r) => r.id === task.input.id); + if (!request2 || request2.state === "pending") { + await runtime.sleepUntil(Date.now() + 1e3, { phase: "wait" }, context); + return; + } + const conversation = await runtime.conversation(runtime.conversationId, context); + await runtime.commit(async (tx) => { + await conversation.submit(tx, { type: "input", content: request2.message, requestId: `credential:${request2.id}` }); + return { status: "terminal", outcome: { status: "completed", result: null } }; + }, context); + } + }, + abort: (_task, runtime, context) => runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context) +}); +var CredentialRequests = defineExtension({ name: "sausage.credentials", tasks: [Wait] }); +async function discoverRequests(harness2, context) { + for (const request2 of Deno.core.ops.op_credential_requests()) { + const conversation = await harness2.conversation(Number(request2.conversation), context); + if (!conversation) continue; + await conversation.commit(async (tx) => { + const recorded = await tx.doc(Requests, conversation.id); + if (recorded.ids.includes(request2.id)) return; + await tx.createTask(Wait, { id: request2.id }, { ownership: { kind: "conversation" }, background: true }); + recorded.ids.push(request2.id); + }, context); + } } +async function startRequests(harness2, context) { + await discoverRequests(harness2, context); + let active = true; + const pending = (async () => { + while (active) { + await Deno.core.ops.op_credential_next(); + if (active) await discoverRequests(harness2, context); + } + })(); + return async () => { + active = false; + await pending; + }; +} + +// main.ts +var proxy = Deno.core.ops.op_model_proxy(); +if (!proxy?.base_url || !proxy?.api_key) throw new Error("The llm-proxy credential is required"); var models = createModels(); var provider2 = openaiProvider(); var model2 = { @@ -44435,7 +44492,7 @@ var model2 = { name: "gpt-6.1-sol", provider: "openai", api: "openai-responses", - baseUrl: `${process.env.PI_PROXY_BASE_URL}/v1`, + baseUrl: `${proxy.base_url}/v1`, reasoning: true, input: ["text"], thinkingLevelMap: { off: null, minimal: null }, @@ -44449,21 +44506,22 @@ models.setProvider({ getModels: () => [model2], auth: { apiKey: { name: "Sausage proxy", resolve: async () => ({ auth: { - apiKey: process.env.PI_PROXY_API_KEY, + apiKey: proxy.api_key, baseUrl: model2.baseUrl, headers: { "Shopify-Usage-Tag": JSON.stringify(["pi", "sausage"]) } }, - source: "PI_PROXY_API_KEY" + source: "llm-proxy" }) } } }); var registry = createRegistry(); registry.install(CodingTools); +registry.install(CredentialRequests); var harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry, env: (target) => environment(target.cwd ?? "/", target.conversationId) }, BACKGROUND_CONTEXT); -var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/", extensions: [CodingTools] } }); +var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/", extensions: [CodingTools, CredentialRequests] } }); var watch = await root.watch(BACKGROUND_CONTEXT); var emitted = /* @__PURE__ */ new Map(); var partial = ""; @@ -44488,6 +44546,7 @@ for (const entry2 of watch.value.entries) { watch.start((event) => { if (event.kind === "value") render(event.value); }); +var stopRequests = await startRequests(harness, BACKGROUND_CONTEXT); harness.resume(); await harness.waitForIdle(BACKGROUND_CONTEXT); while (true) { @@ -44503,5 +44562,6 @@ while (true) { Deno.core.ops.op_output("\n"); if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`); } +await stopRequests(); await watch.stop(); await harness.close(BACKGROUND_CONTEXT); diff --git a/host/js/credentials.ts b/host/js/credentials.ts new file mode 100644 index 0000000..b24b9bd --- /dev/null +++ b/host/js/credentials.ts @@ -0,0 +1,49 @@ +// Durable, background credential waits. Admission and completion share one SQLite commit. +import { defineDoc, defineTask, defineExtension } from '@earendil-works/pi-durable'; +const Requests = defineDoc<{ ids: string[] }>({ + kind: 'sausage.credential.requests', version: 1, scope: 'conversation', + history: 'latest', fork: 'initial', initial: () => ({ ids: [] }), checkpointWhen: () => false, +}); +const Wait = defineTask<{ id: string }, { phase: 'wait' }, null>({ + name: 'sausage.credential.wait', version: 1, initial: () => ({ phase: 'wait' }), + phases: { + wait: async (task, runtime, context) => { + const request = Deno.core.ops.op_credential_requests().find((r: any) => r.id === task.input.id); + if (!request || request.state === 'pending') { + await runtime.sleepUntil(Date.now() + 1000, { phase: 'wait' }, context); + return; + } + const conversation = (await runtime.conversation(runtime.conversationId, context))!; + await runtime.commit(async (tx) => { + await conversation.submit(tx, { type: 'input', content: request.message, requestId: `credential:${request.id}` }); + return { status: 'terminal', outcome: { status: 'completed', result: null } }; + }, context); + }, + }, + abort: (_task, runtime, context) => runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), context), +}); +export const CredentialRequests = defineExtension({ name: 'sausage.credentials', tasks: [Wait] }); +export async function discoverRequests(harness: any, context: any) { + for (const request of Deno.core.ops.op_credential_requests()) { + const conversation = await harness.conversation(Number(request.conversation), context); + if (!conversation) continue; + await conversation.commit(async (tx: any) => { + const recorded = await tx.doc(Requests, conversation.id); + if (recorded.ids.includes(request.id)) return; + await tx.createTask(Wait, { id: request.id }, { ownership: { kind: 'conversation' }, background: true }); + recorded.ids.push(request.id); + }, context); + } +} +export async function startRequests(harness: any, context: any) { + await discoverRequests(harness, context); + // Filed notifications start waits. The deadline reconciles other handles and lost notifications. + let active = true; + const pending = (async () => { + while (active) { + await Deno.core.ops.op_credential_next(); + if (active) await discoverRequests(harness, context); + } + })(); + return async () => { active = false; await pending; }; +} diff --git a/host/js/durable-test-bundle.js b/host/js/durable-test-bundle.js new file mode 100644 index 0000000..6ad5b03 --- /dev/null +++ b/host/js/durable-test-bundle.js @@ -0,0 +1,20519 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + try { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + } catch (e) { + throw mod = 0, e; + } +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// node_modules/partial-json/dist/options.js +var require_options = __commonJS({ + "node_modules/partial-json/dist/options.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Allow = exports.ALL = exports.COLLECTION = exports.ATOM = exports.SPECIAL = exports.INF = exports._INFINITY = exports.INFINITY = exports.NAN = exports.BOOL = exports.NULL = exports.OBJ = exports.ARR = exports.NUM = exports.STR = void 0; + exports.STR = 1; + exports.NUM = 2; + exports.ARR = 4; + exports.OBJ = 8; + exports.NULL = 16; + exports.BOOL = 32; + exports.NAN = 64; + exports.INFINITY = 128; + exports._INFINITY = 256; + exports.INF = exports.INFINITY | exports._INFINITY; + exports.SPECIAL = exports.NULL | exports.BOOL | exports.INF | exports.NAN; + exports.ATOM = exports.STR | exports.NUM | exports.SPECIAL; + exports.COLLECTION = exports.ARR | exports.OBJ; + exports.ALL = exports.ATOM | exports.COLLECTION; + exports.Allow = { STR: exports.STR, NUM: exports.NUM, ARR: exports.ARR, OBJ: exports.OBJ, NULL: exports.NULL, BOOL: exports.BOOL, NAN: exports.NAN, INFINITY: exports.INFINITY, _INFINITY: exports._INFINITY, INF: exports.INF, SPECIAL: exports.SPECIAL, ATOM: exports.ATOM, COLLECTION: exports.COLLECTION, ALL: exports.ALL }; + exports.default = exports.Allow; + } +}); + +// node_modules/partial-json/dist/index.js +var require_dist = __commonJS({ + "node_modules/partial-json/dist/index.js"(exports) { + "use strict"; + var __createBinding = exports && exports.__createBinding || (Object.create ? (function(o, m, k, k2) { + if (k2 === void 0) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { + return m[k]; + } }; + } + Object.defineProperty(o, k2, desc); + }) : (function(o, m, k, k2) { + if (k2 === void 0) k2 = k; + o[k2] = m[k]; + })); + var __exportStar = exports && exports.__exportStar || function(m, exports2) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports2, p)) __createBinding(exports2, m, p); + }; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Allow = exports.MalformedJSON = exports.PartialJSON = exports.parseJSON = exports.parse = void 0; + var options_1 = require_options(); + Object.defineProperty(exports, "Allow", { enumerable: true, get: function() { + return options_1.Allow; + } }); + __exportStar(require_options(), exports); + var PartialJSON = class extends Error { + }; + exports.PartialJSON = PartialJSON; + var MalformedJSON = class extends Error { + }; + exports.MalformedJSON = MalformedJSON; + function parseJSON(jsonString, allowPartial = options_1.Allow.ALL) { + if (typeof jsonString !== "string") { + throw new TypeError(`expecting str, got ${typeof jsonString}`); + } + if (!jsonString.trim()) { + throw new Error(`${jsonString} is empty`); + } + return _parseJSON(jsonString.trim(), allowPartial); + } + exports.parseJSON = parseJSON; + var _parseJSON = (jsonString, allow) => { + const length = jsonString.length; + let index = 0; + const markPartialJSON = (msg) => { + throw new PartialJSON(`${msg} at position ${index}`); + }; + const throwMalformedError = (msg) => { + throw new MalformedJSON(`${msg} at position ${index}`); + }; + const parseAny = () => { + skipBlank(); + if (index >= length) + markPartialJSON("Unexpected end of input"); + if (jsonString[index] === '"') + return parseStr(); + if (jsonString[index] === "{") + return parseObj(); + if (jsonString[index] === "[") + return parseArr(); + if (jsonString.substring(index, index + 4) === "null" || options_1.Allow.NULL & allow && length - index < 4 && "null".startsWith(jsonString.substring(index))) { + index += 4; + return null; + } + if (jsonString.substring(index, index + 4) === "true" || options_1.Allow.BOOL & allow && length - index < 4 && "true".startsWith(jsonString.substring(index))) { + index += 4; + return true; + } + if (jsonString.substring(index, index + 5) === "false" || options_1.Allow.BOOL & allow && length - index < 5 && "false".startsWith(jsonString.substring(index))) { + index += 5; + return false; + } + if (jsonString.substring(index, index + 8) === "Infinity" || options_1.Allow.INFINITY & allow && length - index < 8 && "Infinity".startsWith(jsonString.substring(index))) { + index += 8; + return Infinity; + } + if (jsonString.substring(index, index + 9) === "-Infinity" || options_1.Allow._INFINITY & allow && 1 < length - index && length - index < 9 && "-Infinity".startsWith(jsonString.substring(index))) { + index += 9; + return -Infinity; + } + if (jsonString.substring(index, index + 3) === "NaN" || options_1.Allow.NAN & allow && length - index < 3 && "NaN".startsWith(jsonString.substring(index))) { + index += 3; + return NaN; + } + return parseNum(); + }; + const parseStr = () => { + const start = index; + let escape = false; + index++; + while (index < length && (jsonString[index] !== '"' || escape && jsonString[index - 1] === "\\")) { + escape = jsonString[index] === "\\" ? !escape : false; + index++; + } + if (jsonString.charAt(index) == '"') { + try { + return JSON.parse(jsonString.substring(start, ++index - Number(escape))); + } catch (e) { + throwMalformedError(String(e)); + } + } else if (options_1.Allow.STR & allow) { + try { + return JSON.parse(jsonString.substring(start, index - Number(escape)) + '"'); + } catch (e) { + return JSON.parse(jsonString.substring(start, jsonString.lastIndexOf("\\")) + '"'); + } + } + markPartialJSON("Unterminated string literal"); + }; + const parseObj = () => { + index++; + skipBlank(); + const obj = {}; + try { + while (jsonString[index] !== "}") { + skipBlank(); + if (index >= length && options_1.Allow.OBJ & allow) + return obj; + const key = parseStr(); + skipBlank(); + index++; + try { + const value2 = parseAny(); + obj[key] = value2; + } catch (e) { + if (options_1.Allow.OBJ & allow) + return obj; + else + throw e; + } + skipBlank(); + if (jsonString[index] === ",") + index++; + } + } catch (e) { + if (options_1.Allow.OBJ & allow) + return obj; + else + markPartialJSON("Expected '}' at end of object"); + } + index++; + return obj; + }; + const parseArr = () => { + index++; + const arr = []; + try { + while (jsonString[index] !== "]") { + arr.push(parseAny()); + skipBlank(); + if (jsonString[index] === ",") { + index++; + } + } + } catch (e) { + if (options_1.Allow.ARR & allow) { + return arr; + } + markPartialJSON("Expected ']' at end of array"); + } + index++; + return arr; + }; + const parseNum = () => { + if (index === 0) { + if (jsonString === "-") + throwMalformedError("Not sure what '-' is"); + try { + return JSON.parse(jsonString); + } catch (e) { + if (options_1.Allow.NUM & allow) + try { + return JSON.parse(jsonString.substring(0, jsonString.lastIndexOf("e"))); + } catch (e2) { + } + throwMalformedError(String(e)); + } + } + const start = index; + if (jsonString[index] === "-") + index++; + while (jsonString[index] && ",]}".indexOf(jsonString[index]) === -1) + index++; + if (index == length && !(options_1.Allow.NUM & allow)) + markPartialJSON("Unterminated number literal"); + try { + return JSON.parse(jsonString.substring(start, index)); + } catch (e) { + if (jsonString.substring(start, index) === "-") + markPartialJSON("Not sure what '-' is"); + try { + return JSON.parse(jsonString.substring(start, jsonString.lastIndexOf("e"))); + } catch (e2) { + throwMalformedError(String(e2)); + } + } + }; + const skipBlank = () => { + while (index < length && " \n\r ".includes(jsonString[index])) { + index++; + } + }; + return parseAny(); + }; + var parse = parseJSON; + exports.parse = parse; + } +}); + +// node_modules/@earendil-works/pi-telemetry/dist/metrics.js +function createTypedMetrics(metrics, schemas) { + const definitions = /* @__PURE__ */ new Map(); + for (const schema of schemas) { + for (const [name, definition] of Object.entries(schema.metrics ?? {})) + definitions.set(name, definition); + } + return { + counter: (name) => metrics.counter(name, definitions.get(name)), + upDownCounter: (name) => metrics.upDownCounter(name, definitions.get(name)), + histogram: (name) => metrics.histogram(name, definitions.get(name)) + }; +} + +// node_modules/@earendil-works/pi-telemetry/dist/noop.js +async function startNoopSpan(_options, callback) { + return callback(noopTelemetrySpan); +} +var noopCounter = Object.freeze({ add: () => { +} }); +var noopUpDownCounter = Object.freeze({ add: () => { +} }); +var noopHistogram = Object.freeze({ record: () => { +} }); +var NOOP_TELEMETRY_METRICS = Object.freeze({ + counter: () => noopCounter, + upDownCounter: () => noopUpDownCounter, + histogram: () => noopHistogram +}); +var NOOP_TELEMETRY_PROPAGATION = Object.freeze({ + inject: () => void 0, + extract: () => NOOP_TELEMETRY_CONTEXT +}); +var noopTelemetrySpan = { + metrics: NOOP_TELEMETRY_METRICS, + propagation: NOOP_TELEMETRY_PROPAGATION, + startSpan: startNoopSpan, + addLink: () => { + }, + addEvent: () => { + }, + setAttributes: () => { + }, + setStatus: () => { + } +}; +Object.freeze(noopTelemetrySpan); +var NOOP_TELEMETRY_CONTEXT = noopTelemetrySpan; + +// node_modules/@earendil-works/pi-telemetry/dist/index.js +function defineTelemetrySchema(schema) { + return schema; +} +function bindTypedSpanStarter(telemetryContext) { + const startSpan = (name, attributes, callback) => telemetryContext.startSpan({ name, attributes }, (span) => callback(span, bindTypedSpanStarter(span))); + return startSpan; +} +function createTypedSpanStarter(telemetryContext, _schemas) { + return bindTypedSpanStarter(telemetryContext); +} + +// node_modules/@earendil-works/chord/dist/context/index.js +var ABORT_SIGNAL_CONTEXT_KEY = Object.freeze({ + token: /* @__PURE__ */ Symbol("chord.abortSignal") +}); +var TELEMETRY_CONTEXT_KEY = Object.freeze({ + token: /* @__PURE__ */ Symbol("chord.telemetry") +}); +var BaseContext = class { + get abortSignal() { + return this.value(ABORT_SIGNAL_CONTEXT_KEY); + } + get telemetry() { + return this.value(TELEMETRY_CONTEXT_KEY) ?? NOOP_TELEMETRY_CONTEXT; + } +}; +var EmptyContext = class extends BaseContext { + #name; + constructor(name) { + super(); + this.#name = name; + } + value(_key) { + return void 0; + } + toString() { + return this.#name; + } +}; +var ContextValue = class extends BaseContext { + #parent; + #key; + #value; + constructor(parent, key, value2) { + super(); + this.#parent = parent; + this.#key = key; + this.#value = value2; + } + value(key) { + if (key.token === this.#key.token) + return this.#value; + return this.#parent.value(key); + } + toString() { + return `${this.#parent}.WithValue(${this.#key.token.description ?? "anonymous"})`; + } +}; +var BACKGROUND_CONTEXT = new EmptyContext("[Context BACKGROUND_CONTEXT]"); +var TODO_CONTEXT = new EmptyContext("[Context TODO_CONTEXT]"); +function withContextValue(key, value2, parent) { + return new ContextValue(parent, key, value2); +} +function withTelemetry(telemetry, context) { + return withContextValue(TELEMETRY_CONTEXT_KEY, telemetry, context); +} +function withAbortSignal(signal, context) { + const parentSignal = context.abortSignal; + const combined = parentSignal === void 0 ? signal : AbortSignal.any([parentSignal, signal]); + return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, combined, context); +} +function withoutAbortSignal(context) { + return withContextValue(ABORT_SIGNAL_CONTEXT_KEY, void 0, context); +} +function awaitWithContext(promise, context) { + const signal = context.abortSignal; + if (signal === void 0) + return promise; + if (signal.aborted) + return Promise.reject(abortError(signal)); + return new Promise((resolve2, reject2) => { + const onAbort = () => reject2(abortError(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + void promise.then((value2) => { + signal.removeEventListener("abort", onAbort); + resolve2(value2); + }, (error) => { + signal.removeEventListener("abort", onAbort); + reject2(error); + }); + }); +} +function abortError(signal) { + const reason = signal.reason; + return reason instanceof Error ? reason : new DOMException("The operation was aborted", "AbortError"); +} + +// node_modules/@earendil-works/pi-ai/dist/utils/event-stream.js +var FifoQueue = class { + incoming = []; + outgoing = []; + get length() { + return this.incoming.length + this.outgoing.length; + } + enqueue(value2) { + this.incoming.push(value2); + } + dequeue() { + if (this.outgoing.length === 0) { + while (this.incoming.length > 0) { + this.outgoing.push(this.incoming.pop()); + } + } + return this.outgoing.pop(); + } +}; +var EventStream = class { + queue = new FifoQueue(); + waiting = new FifoQueue(); + done = false; + finalResultPromise; + resolveFinalResult; + isComplete; + extractResult; + constructor(isComplete, extractResult) { + this.isComplete = isComplete; + this.extractResult = extractResult; + this.finalResultPromise = new Promise((resolve2) => { + this.resolveFinalResult = resolve2; + }); + } + push(event) { + if (this.done) + return; + if (this.isComplete(event)) { + this.done = true; + this.resolveFinalResult(this.extractResult(event)); + } + const waiter = this.waiting.dequeue(); + if (waiter) { + waiter({ value: event, done: false }); + } else { + this.queue.enqueue(event); + } + } + end(result) { + this.done = true; + if (result !== void 0) { + this.resolveFinalResult(result); + } + while (this.waiting.length > 0) { + const waiter = this.waiting.dequeue(); + waiter({ value: void 0, done: true }); + } + } + async *[Symbol.asyncIterator]() { + while (true) { + if (this.queue.length > 0) { + yield this.queue.dequeue(); + } else if (this.done) { + return; + } else { + const result = await new Promise((resolve2) => this.waiting.enqueue(resolve2)); + if (result.done) + return; + yield result.value; + } + } + } + result() { + return this.finalResultPromise; + } +}; +var AssistantMessageEventStream = class extends EventStream { + #startedAt = Date.now(); + #startedAtMonotonic = performance.now(); + constructor() { + super((event) => event.type === "done" || event.type === "error", (event) => { + if (event.type === "done") { + return event.message; + } else if (event.type === "error") { + return event.error; + } + throw new Error("Unexpected event type for final result"); + }); + } + push(event) { + if (event.type === "done") + this.#time(event.message); + else if (event.type === "error") + this.#time(event.error); + super.push(event); + } + end(result) { + if (result !== void 0) + this.#time(result); + super.end(result); + } + #time(message) { + if (this.done || message.durationMs !== void 0 || message.timestamp < this.#startedAt) + return; + message.durationMs = Math.max(0, Math.round(performance.now() - this.#startedAtMonotonic)); + } +}; +function createAssistantMessageEventStream() { + return new AssistantMessageEventStream(); +} + +// node_modules/@earendil-works/pi-ai/dist/api/lazy.js +function createSetupErrorMessage(model2, error, timestamp) { + return { + role: "assistant", + content: [], + api: model2.api, + provider: model2.provider, + model: model2.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } + }, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp + }; +} +function hasResult(source) { + return typeof source.result === "function"; +} +async function forwardStream(target, source, observer, observeResult) { + let result; + for await (const event of source) { + target.push(event); + observer?.onEvent?.(event); + if (event.type === "done") + result = event.message; + else if (event.type === "error") + result = event.error; + } + if (hasResult(source)) + result = await source.result(); + target.end(result); + observeResult(result); +} +function lazyStream(model2, setup, observer) { + const startedAt = Date.now(); + const outer = new AssistantMessageEventStream(); + let observedResult = false; + const observeResult = (result) => { + if (observedResult) + return; + observedResult = true; + observer?.onResult?.(result); + }; + setup().then((inner) => forwardStream(outer, inner, observer, observeResult)).catch((error) => { + const message = createSetupErrorMessage(model2, error, startedAt); + const event = { type: "error", reason: "error", error: message }; + outer.push(event); + observer?.onEvent?.(event); + outer.end(message); + observeResult(message); + }); + return outer; +} + +// node_modules/@earendil-works/pi-ai/dist/auth/context.js +var __rewriteRelativeImportExtension = function(path, preserveJsx) { + if (typeof path === "string" && /^\.\.?\//.test(path)) { + return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function(m, tsx, d, ext, cm) { + return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : d + ext + "." + cm.toLowerCase() + "js"; + }); + } + return path; +}; +var importNodeModule = (specifier) => import(__rewriteRelativeImportExtension(specifier)); +function getProcessEnv() { + const proc = globalThis.process; + return proc?.env; +} +function defaultProviderAuthContext() { + return { + async env(name) { + const value2 = getProcessEnv()?.[name]; + return typeof value2 === "string" && value2.trim().length > 0 ? value2 : void 0; + }, + async fileExists(path) { + try { + const fs = await importNodeModule("node:fs/promises"); + let resolved = path; + if (resolved.startsWith("~")) { + const os = await importNodeModule("node:os"); + resolved = os.homedir() + resolved.slice(1); + } + await fs.access(resolved); + return true; + } catch { + return false; + } + } + }; +} + +// node_modules/@earendil-works/pi-ai/dist/utils/abort.js +function abortReason(signal) { + if (signal.reason !== void 0) + return signal.reason; + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + return error; +} +function operationSignal(signal) { + return signal ?? new AbortController().signal; +} +function raceWithAbortSignal(operation, signal) { + if (signal.aborted) { + void operation.catch(() => { + }); + return Promise.reject(abortReason(signal)); + } + return new Promise((resolve2, reject2) => { + let settled = false; + const cleanup = () => signal.removeEventListener("abort", onAbort); + const onAbort = () => { + if (settled) + return; + settled = true; + cleanup(); + reject2(abortReason(signal)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + void operation.then((value2) => { + if (settled) + return; + settled = true; + cleanup(); + resolve2(value2); + }, (error) => { + if (settled) + return; + settled = true; + cleanup(); + reject2(error); + }); + if (signal.aborted) + onAbort(); + }); +} + +// node_modules/@earendil-works/pi-ai/dist/auth/credential-store.js +var InMemoryCredentialStore = class { + credentials = /* @__PURE__ */ new Map(); + chains = /* @__PURE__ */ new Map(); + /** Serialize tasks per provider id without releasing the chain before active work settles. */ + enqueue(providerId, task, options) { + const signal = operationSignal(options?.signal); + const previous = this.chains.get(providerId) ?? Promise.resolve(); + const queued = (async () => { + await previous.catch(() => { + }); + signal.throwIfAborted(); + return task(); + })(); + const tail = queued.catch(() => { + }); + this.chains.set(providerId, tail); + void tail.then(() => { + if (this.chains.get(providerId) === tail) + this.chains.delete(providerId); + }); + return raceWithAbortSignal(queued, signal); + } + async read(providerId, options) { + options?.signal?.throwIfAborted(); + return this.credentials.get(providerId); + } + async list(options) { + options?.signal?.throwIfAborted(); + return [...this.credentials].map(([providerId, credential]) => ({ providerId, type: credential.type })); + } + modify(providerId, fn, options) { + return this.enqueue(providerId, async () => { + const current = this.credentials.get(providerId); + const next = await fn(current); + options?.signal?.throwIfAborted(); + if (next !== void 0) + this.credentials.set(providerId, next); + return next ?? current; + }, options); + } + delete(providerId, options) { + return this.enqueue(providerId, async () => { + this.credentials.delete(providerId); + }, options); + } +}; + +// node_modules/@earendil-works/pi-ai/dist/utils/diagnostics.js +function formatThrownValue(value2) { + if (value2 instanceof Error) + return value2.message || value2.name; + if (typeof value2 === "string") + return value2; + return String(value2); +} + +// node_modules/@earendil-works/pi-ai/dist/utils/models-error.js +var ModelsError = class extends Error { + code; + constructor(code, message, options) { + super(withCauseDetail(message, options?.cause), options); + this.name = "ModelsError"; + this.code = code; + } +}; +function withCauseDetail(message, cause) { + if (cause === void 0 || cause === null) + return message; + const detail = formatThrownValue(cause).trim(); + if (!detail || message.includes(detail)) + return message; + return `${message}: ${detail}`; +} + +// node_modules/@earendil-works/pi-ai/dist/auth/resolve.js +function resolveProviderAuth(provider2, credentials, authContext, overrides) { + const signal = operationSignal(overrides?.signal); + return raceWithAbortSignal(resolveProviderAuthWithSignal(provider2, credentials, authContext, overrides, signal), signal); +} +async function resolveProviderAuthWithSignal(provider2, credentials, authContext, overrides, signal) { + signal.throwIfAborted(); + const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext; + if (overrides?.apiKey !== void 0 && provider2.auth.apiKey) { + return resolveApiKey(requestAuthContext, provider2.auth.apiKey, provider2.id, { + type: "api_key", + key: overrides.apiKey, + env: overrides.env + }, signal); + } + const stored = await readCredential(credentials, provider2.id, signal); + if (stored) { + if (stored.type === "oauth" && provider2.auth.oauth) { + return resolveStoredOAuth(credentials, provider2.id, provider2.auth.oauth, stored, signal, overrides?.minOAuthValidityMs); + } + if (stored.type === "api_key" && provider2.auth.apiKey) { + const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored; + return resolveApiKey(requestAuthContext, provider2.auth.apiKey, provider2.id, credential, signal); + } + return void 0; + } + return provider2.auth.apiKey ? resolveApiKey(requestAuthContext, provider2.auth.apiKey, provider2.id, void 0, signal) : void 0; +} +function overlayEnvAuthContext(base, env) { + return { + env: async (name) => env[name] || await base.env(name), + fileExists: (path) => base.fileExists(path) + }; +} +var DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1e3; +var DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 15e3; +async function refreshStoredOAuthCredential(credentials, providerId, oauth, needsRefresh, signal) { + let post; + const lockWait = new AbortController(); + const cancelLockWait = () => lockWait.abort(signal.reason); + signal.addEventListener("abort", cancelLockWait, { once: true }); + if (signal.aborted) + cancelLockWait(); + try { + post = await credentials.modify(providerId, async (current) => { + signal.removeEventListener("abort", cancelLockWait); + signal.throwIfAborted(); + if (current?.type !== "oauth") + return void 0; + if (!needsRefresh(current)) + return void 0; + try { + return await oauth.refresh(current, AbortSignal.timeout(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS)); + } catch (error) { + throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error }); + } + }, { signal: lockWait.signal }); + } catch (error) { + if (error instanceof ModelsError) + throw error; + signal.throwIfAborted(); + throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error }); + } finally { + signal.removeEventListener("abort", cancelLockWait); + } + return post?.type === "oauth" ? post : void 0; +} +async function resolveStoredOAuth(credentials, providerId, oauth, stored, signal, minOAuthValidityMs) { + const minimumValidityMs = Math.max(DEFAULT_OAUTH_MINIMUM_VALIDITY_MS, minOAuthValidityMs ?? 0); + const expiresSoon = (credential2) => Date.now() + minimumValidityMs >= credential2.expires; + let credential = stored; + if (expiresSoon(credential)) { + const post = await refreshStoredOAuthCredential(credentials, providerId, oauth, expiresSoon, signal); + if (!post) + return void 0; + credential = post; + if (minOAuthValidityMs !== void 0 && expiresSoon(credential)) { + throw new ModelsError("oauth", `OAuth refresh returned a token that expires too soon for ${providerId}`); + } + } + try { + return { auth: await oauth.toAuth(credential), source: "OAuth" }; + } catch (error) { + throw new ModelsError("oauth", `OAuth auth derivation failed for ${providerId}`, { cause: error }); + } +} +async function resolveApiKey(authContext, apiKey, providerId, credential, signal) { + try { + return await apiKey.resolve({ ctx: authContext, credential, signal }); + } catch (error) { + throw new ModelsError("auth", `API key auth failed for provider ${providerId}`, { cause: error }); + } +} +async function readCredential(credentials, providerId, signal) { + try { + return await credentials.read(providerId, { signal }); + } catch (error) { + throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error }); + } +} + +// node_modules/@earendil-works/pi-ai/dist/models-store.js +var InMemoryModelsStore = class { + entries = /* @__PURE__ */ new Map(); + async read(providerId, options) { + options?.signal?.throwIfAborted(); + const entry2 = this.entries.get(providerId); + return entry2 ? structuredClone(entry2) : void 0; + } + async write(providerId, entry2, options) { + options?.signal?.throwIfAborted(); + this.entries.set(providerId, structuredClone(entry2)); + } + async delete(providerId, options) { + options?.signal?.throwIfAborted(); + this.entries.delete(providerId); + } +}; + +// node_modules/@earendil-works/pi-ai/dist/utils/text.js +function contentText(content, separator = "\n") { + if (typeof content === "string") + return content; + return content.filter((block) => block.type === "text").map((block) => block.text).join(separator); +} +function getSystemMessageText(message) { + const parts = [contentText(message.content)]; + for (const text of Object.values(message.sections ?? {})) { + if (text !== null) + parts.push(text); + } + return parts.filter((part) => part.length > 0).join("\n\n"); +} +function ownedSlice(text, start, end = text.length) { + return `${text.slice(start, end)} `.slice(0, -1); +} + +// node_modules/@earendil-works/pi-ai/dist/telemetry.js +var provider = { type: "string", description: "Provider identifier.", required: true }; +var model = { type: "string", description: "Requested model identifier.", required: true }; +var stop = { + type: "string", + description: "Normalized stop reason.", + values: ["pending", "stop", "length", "toolUse", "error", "aborted", "deferred"] +}; +var request = { + description: "One logical model request, including authentication and provider setup.", + parents: { kind: "any" }, + startAttributes: { + provider, + api: { type: "string", description: "API identifier.", required: true }, + model, + reasoning: { type: "string", description: "Scalar requested reasoning level.", required: false } + }, + endAttributes: { + stop, + "usage.input": { type: "number", description: "Uncached input tokens." }, + "usage.output": { type: "number", description: "Output tokens." }, + "usage.cache_read": { type: "number", description: "Cache-read tokens." }, + "usage.cache_write": { type: "number", description: "Cache-write tokens." }, + "cost.total": { type: "number", description: "Total request cost in USD." }, + "response.id": { type: "string", description: "Provider response identifier.", cardinality: "high" } + }, + events: { + "pi.ai.first_chunk": { description: "First normalized stream event delivered.", attributes: {} }, + "pi.ai.block": { + description: "A completed content block; content is never recorded.", + attributes: { + type: { + type: "string", + description: "Block kind.", + values: ["text", "thinking", "tool_call"], + required: true + }, + index: { type: "number", description: "Content block index.", required: true }, + chars: { type: "number", description: "UTF-16 character count.", required: true }, + tool: { type: "string", description: "Tool name, for tool-call blocks only.", required: false } + } + } + }, + status: { + default: "ok", + errorWhen: "Stop reason is error: provider_error with at most 500 error-message characters. Aborted stays ok." + } +}; +var AI_TELEMETRY_SCHEMA = defineTelemetrySchema({ + version: 1, + spans: { + "pi.ai.request": request, + "pi.ai.poll": { ...request, description: "One deferred-response poll, not the deferred request's lifetime." } + }, + metrics: { + "pi.ai.request.duration": { + kind: "histogram", + description: "Logical request or poll duration.", + unit: "ms", + attributes: { provider, model, stop: { ...stop, required: true } } + }, + "pi.ai.first_chunk.duration": { + kind: "histogram", + description: "Time to the first normalized stream event.", + unit: "ms", + attributes: { provider, model } + }, + "pi.ai.tokens": { + kind: "counter", + description: "Reported model token usage; pending and deferred polls are excluded.", + unit: "token", + attributes: { + provider, + model, + kind: { + type: "string", + description: "Token usage category.", + values: ["input", "output", "cache_read", "cache_write"], + required: true + } + } + }, + "pi.ai.cost": { + kind: "counter", + description: "Reported model cost; pending and deferred polls are excluded.", + unit: "USD", + attributes: { provider, model } + } + } +}); +function createInstruments(adapter) { + const metrics = createTypedMetrics(adapter, [AI_TELEMETRY_SCHEMA]); + return { + requestDuration: metrics.histogram("pi.ai.request.duration"), + firstChunkDuration: metrics.histogram("pi.ai.first_chunk.duration"), + tokens: metrics.counter("pi.ai.tokens"), + cost: metrics.counter("pi.ai.cost") + }; +} +var instrumentCache = /* @__PURE__ */ new WeakMap(); +function observeModelRequest(requestedModel, options, poll) { + const started = performance.now(); + const attributes = { provider: requestedModel.provider, model: requestedModel.id }; + const adapter = options?.telemetry?.metrics ?? NOOP_TELEMETRY_METRICS; + let instruments = instrumentCache.get(adapter); + if (!instruments) { + instruments = createInstruments(adapter); + instrumentCache.set(adapter, instruments); + } + const { requestDuration, firstChunkDuration, tokens, cost } = instruments; + let span; + let finish; + let first = true; + const toolChars = /* @__PURE__ */ new Map(); + const finished = new Promise((resolve2) => { + finish = resolve2; + }); + const run2 = (activeSpan) => { + span = activeSpan; + return finished; + }; + const startAttributes = { + ...attributes, + api: requestedModel.api, + ...typeof options?.reasoning === "string" ? { reasoning: options.reasoning } : {} + }; + const start = createTypedSpanStarter(options?.telemetry ?? NOOP_TELEMETRY_CONTEXT, [AI_TELEMETRY_SCHEMA]); + if (poll) + void start("pi.ai.poll", startAttributes, run2); + else + void start("pi.ai.request", startAttributes, run2); + return { + onEvent(event) { + if (first) { + first = false; + span.addEvent("pi.ai.first_chunk"); + firstChunkDuration.record(performance.now() - started, attributes, span); + } + if (event.type === "toolcall_delta") { + toolChars.set(event.contentIndex, (toolChars.get(event.contentIndex) ?? 0) + event.delta.length); + } else if (event.type === "text_end" || event.type === "thinking_end") { + span.addEvent("pi.ai.block", { + type: event.type === "text_end" ? "text" : "thinking", + index: event.contentIndex, + chars: event.content.length + }); + } else if (event.type === "toolcall_end") { + const chars = toolChars.get(event.contentIndex) ?? 0; + toolChars.delete(event.contentIndex); + span.addEvent("pi.ai.block", { + type: "tool_call", + index: event.contentIndex, + chars, + tool: event.toolCall.name + }); + } + }, + onResult(message) { + if (message) { + span.setAttributes({ + stop: message.stopReason, + "usage.input": message.usage.input, + "usage.output": message.usage.output, + "usage.cache_read": message.usage.cacheRead, + "usage.cache_write": message.usage.cacheWrite, + "cost.total": message.usage.cost.total, + "response.id": message.responseId + }); + if (message.stopReason === "error") { + span.setStatus({ + status: "error", + error: { name: "provider_error", message: ownedSlice(message.errorMessage ?? "", 0, 500) } + }); + } + requestDuration.record(performance.now() - started, { ...attributes, stop: message.stopReason }, span); + if (!poll || message.stopReason !== "pending" && message.stopReason !== "deferred") { + tokens.add(message.usage.input, { ...attributes, kind: "input" }); + tokens.add(message.usage.output, { ...attributes, kind: "output" }); + tokens.add(message.usage.cacheRead, { ...attributes, kind: "cache_read" }); + tokens.add(message.usage.cacheWrite, { ...attributes, kind: "cache_write" }); + cost.add(message.usage.cost.total, attributes); + } + } + finish(); + } + }; +} + +// node_modules/@earendil-works/pi-ai/dist/utils/identity.js +var PI_ATTRIBUTION = { name: "pi", url: "https://pi.dev", categories: ["cli-agent"] }; +var OPENCODE_HOST = "opencode.ai"; +function mergeClientIdentity(...sources) { + let merged; + for (const source of sources) { + if (!source) + continue; + merged = { ...merged }; + if (source.userAgent !== void 0) + merged.userAgent = source.userAgent; + if (source.attribution !== void 0) + merged.attribution = source.attribution; + if (source.version !== void 0) + merged.version = source.version; + } + return merged; +} +function matchesHost(baseUrl, expectedHost) { + try { + return new URL(baseUrl).hostname === expectedHost; + } catch { + return false; + } +} +function isOpenCode(target) { + return target.provider === "opencode" || target.provider === "opencode-go" || matchesHost(target.baseUrl, OPENCODE_HOST); +} +function getOpenCodeSessionHeaders(target, sessionId) { + return sessionId && isOpenCode(target) ? { "x-opencode-session": sessionId } : void 0; +} +function isMutableModels(models2) { + return "setProvider" in models2 && "deleteProvider" in models2 && "clearProviders" in models2; +} +function withClientIdentity(models2, identity) { + const withIdentity = (options) => ({ ...options, identity: mergeClientIdentity(identity, options?.identity) }); + const wrapped = { + getProviders: () => models2.getProviders(), + getProvider: (id) => models2.getProvider(id), + getModels: (provider2) => models2.getModels(provider2), + getModel: (provider2, id) => models2.getModel(provider2, id), + getModelsOfType: (type, provider2) => models2.getModelsOfType(type, provider2), + getModelOfType: (type, provider2, id) => models2.getModelOfType(type, provider2, id), + getAllModels: (provider2) => models2.getAllModels(provider2), + refresh: (options) => models2.refresh(options), + checkAuth: (providerId, options) => models2.checkAuth(providerId, options), + getAvailable: (providerId, options) => models2.getAvailable(providerId, options), + getAvailableOfType: (type, providerId, options) => models2.getAvailableOfType(type, providerId, options), + getAllAvailable: (providerId, options) => models2.getAllAvailable(providerId, options), + getAuth: models2.getAuth.bind(models2), + login: (providerId, type, interaction, options) => models2.login(providerId, type, interaction, options), + logout: (providerId, options) => models2.logout(providerId, options), + stream: (model2, context, options) => models2.stream(model2, context, withIdentity(options)), + complete: (model2, context, options) => models2.complete(model2, context, withIdentity(options)), + streamSimple: (model2, context, options) => models2.streamSimple(model2, context, withIdentity(options)), + completeSimple: (model2, context, options) => models2.completeSimple(model2, context, withIdentity(options)), + streamDeferred: (model2, handle, options) => models2.streamDeferred(model2, handle, withIdentity(options)), + fetchDeferred: (model2, handle, options) => models2.fetchDeferred(model2, handle, withIdentity(options)), + cancelDeferred: (model2, handle, options) => models2.cancelDeferred(model2, handle, withIdentity(options)), + generateImages: (model2, context, options) => models2.generateImages(model2, context, withIdentity(options)), + classify: (model2, context, options) => models2.classify(model2, context, withIdentity(options)) + }; + if (!isMutableModels(models2)) + return wrapped; + const mutable = { + ...wrapped, + setProvider: (provider2) => models2.setProvider(provider2), + deleteProvider: (id) => models2.deleteProvider(id), + clearProviders: () => models2.clearProviders() + }; + return mutable; +} + +// node_modules/@earendil-works/pi-ai/dist/utils/model-operations.js +function getModelType(model2) { + return model2.type ?? "chat"; +} +function isModelType(model2, type) { + return getModelType(model2) === type; +} +function assertChatModel(model2) { + if (!isModelType(model2, "chat")) { + throw new ModelsError("provider", `Model ${model2.provider}/${model2.id} is not a chat model`); + } +} +function assertImageModel(model2) { + if (!isModelType(model2, "image")) { + throw new ModelsError("provider", `Model ${model2.provider}/${model2.id} is not an image model`); + } +} +function assertClassifierModel(model2) { + if (!isModelType(model2, "classifier")) { + throw new ModelsError("provider", `Model ${model2.provider}/${model2.id} is not a classifier model`); + } +} +function assertClassifierInputSupported(model2, context) { + if (context.images?.length && !model2.input.includes("image")) { + throw new ModelsError("provider", `Model ${model2.provider}/${model2.id} does not accept image input`); + } +} +function imageErrorResult(model2, error, aborted = false) { + return { + api: model2.api, + provider: model2.provider, + model: model2.id, + output: [], + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now() + }; +} +function classifierErrorResult(model2, error, aborted = false) { + return { + api: model2.api, + provider: model2.provider, + model: model2.id, + answers: {}, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now() + }; +} + +// node_modules/@earendil-works/pi-ai/dist/utils/transcript.js +function createInitialSystemMessage(systemPrompt, tools) { + const hasSystemPrompt = systemPrompt !== void 0 && systemPrompt.length > 0; + const hasTools = tools !== void 0 && tools.length > 0; + if (!hasSystemPrompt && !hasTools) + return void 0; + return { + role: "system", + content: systemPrompt ?? "", + ...hasTools ? { toolsAdded: tools } : {}, + timestamp: 0 + }; +} +function normalizeContext(context) { + const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools); + const messages = initialMessage ? [initialMessage, ...context.messages] : context.messages; + return { messages }; +} +function isSystemMessage(message) { + return message.role === "system"; +} +function getCurrentTools(messages) { + const tools = /* @__PURE__ */ new Map(); + for (const message of messages) { + if (!isSystemMessage(message)) + continue; + for (const tool of message.toolsRemoved ?? []) + tools.delete(tool.name); + for (const tool of message.toolsAdded ?? []) + tools.set(tool.name, tool); + } + return [...tools.values()]; +} +function toToolDeclaration(tool) { + return { + name: tool.name, + description: tool.description, + parameters: JSON.parse(JSON.stringify(tool.parameters)), + ...tool.constrainedSampling === void 0 ? {} : { constrainedSampling: tool.constrainedSampling } + }; +} +function declarationsEqual(left, right) { + return JSON.stringify(toToolDeclaration(left)) === JSON.stringify(toToolDeclaration(right)); +} + +// node_modules/@earendil-works/pi-ai/dist/models.js +var KNOWN_MODEL_TYPES = { chat: true, image: true, classifier: true }; +function hasKnownModelType(model2) { + return Object.hasOwn(KNOWN_MODEL_TYPES, getModelType(model2)); +} +function withKnownModelTypes(entry2) { + return { ...entry2, models: entry2.models.filter(hasKnownModelType) }; +} +function mergeHeaders(base, override) { + if (!base && !override) + return void 0; + const merged = { ...base }; + for (const [name, value2] of Object.entries(override ?? {})) { + const lowerName = name.toLowerCase(); + for (const existingName of Object.keys(merged)) { + if (existingName.toLowerCase() === lowerName) + delete merged[existingName]; + } + merged[name] = value2; + } + return merged; +} +var ModelsImpl = class { + providers = /* @__PURE__ */ new Map(); + credentials; + modelsStore; + authContext; + identity; + refreshGenerations = /* @__PURE__ */ new Map(); + refreshControllers = /* @__PURE__ */ new Map(); + publicationChains = /* @__PURE__ */ new Map(); + constructor(options) { + this.credentials = options?.credentials ?? new InMemoryCredentialStore(); + this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore(); + this.authContext = options?.authContext ?? defaultProviderAuthContext(); + this.identity = options?.identity; + } + setProvider(provider2) { + this.supersedeProviderRefresh(provider2.id); + this.providers.set(provider2.id, provider2); + } + deleteProvider(id) { + this.supersedeProviderRefresh(id); + this.providers.delete(id); + } + clearProviders() { + for (const id of /* @__PURE__ */ new Set([...this.providers.keys(), ...this.refreshControllers.keys()])) { + this.supersedeProviderRefresh(id); + } + this.providers.clear(); + } + getProviders() { + return Array.from(this.providers.values()); + } + getProvider(id) { + return this.providers.get(id); + } + getModels(provider2) { + if (provider2 !== void 0) { + const entry2 = this.providers.get(provider2); + if (!entry2) + return []; + try { + return entry2.getModels(); + } catch { + return []; + } + } + const models2 = []; + for (const entry2 of this.providers.values()) { + try { + models2.push(...entry2.getModels()); + } catch { + } + } + return models2; + } + getAllModels(provider2) { + if (provider2 !== void 0) { + const entry2 = this.providers.get(provider2); + if (!entry2) + return []; + try { + return entry2.getAllModels?.() ?? entry2.getModels(); + } catch { + return []; + } + } + const models2 = []; + for (const entry2 of this.providers.values()) { + try { + models2.push(...entry2.getAllModels?.() ?? entry2.getModels()); + } catch { + } + } + return models2; + } + getModelsOfType(type, provider2) { + return this.getAllModels(provider2).filter((model2) => isModelType(model2, type)); + } + getModel(provider2, id) { + return this.getModels(provider2).find((model2) => model2.id === id); + } + getModelOfType(type, provider2, id) { + return this.getModelsOfType(type, provider2).find((model2) => model2.id === id); + } + supersedeProviderRefresh(providerId) { + const generation = (this.refreshGenerations.get(providerId) ?? 0) + 1; + this.refreshGenerations.set(providerId, generation); + const previous = this.refreshControllers.get(providerId); + if (previous) { + this.refreshControllers.delete(providerId); + previous.abort(); + } + return generation; + } + beginProviderRefresh(providerId) { + const generation = this.supersedeProviderRefresh(providerId); + const controller = new AbortController(); + this.refreshControllers.set(providerId, controller); + return { generation, controller }; + } + publishProviderModels(providerId, generation, signal, publication) { + const previous = this.publicationChains.get(providerId) ?? Promise.resolve(); + const queued = (async () => { + await previous.catch(() => { + }); + if (signal.aborted || this.refreshGenerations.get(providerId) !== generation) + return false; + if (publication.persist === null) { + await this.modelsStore.delete(providerId, { signal }); + } else if (publication.persist !== void 0) { + await this.modelsStore.write(providerId, structuredClone(publication.persist), { signal }); + } + if (signal.aborted || this.refreshGenerations.get(providerId) !== generation) + return false; + publication.update?.(); + return true; + })(); + const tail = queued.catch(() => { + }); + this.publicationChains.set(providerId, tail); + void tail.then(() => { + if (this.publicationChains.get(providerId) === tail) + this.publicationChains.delete(providerId); + }); + return raceWithAbortSignal(queued, signal); + } + async runProviderRefreshPhase(provider2, credential, allowNetwork, force, generation, signal) { + const stored = await this.modelsStore.read(provider2.id, { signal }); + await provider2.refreshModels({ + credential, + stored: stored ? withKnownModelTypes(structuredClone(stored)) : void 0, + publish: (publication) => this.publishProviderModels(provider2.id, generation, signal, publication), + allowNetwork, + force: allowNetwork ? force : void 0, + signal, + baseUrl: provider2.baseUrl + }); + } + async refresh(options = {}) { + const allowNetwork = options.allowNetwork ?? true; + const callerSignal = operationSignal(options.signal); + const errors = /* @__PURE__ */ new Map(); + if (callerSignal.aborted) + return { aborted: true, errors }; + const selected = options.providers ? new Set(options.providers) : void 0; + const refreshable = Array.from(this.providers.values()).filter((provider2) => provider2.refreshModels !== void 0 && (!selected || selected.has(provider2.id))); + const refresh = Promise.all(refreshable.map(async (provider2) => { + const { generation, controller } = this.beginProviderRefresh(provider2.id); + const signal = AbortSignal.any([callerSignal, controller.signal]); + const operation = (async () => { + let storedCredential; + let credentialError; + try { + storedCredential = await this.readCredential(provider2.id, signal); + } catch (error) { + credentialError = error; + } + await this.runProviderRefreshPhase(provider2, storedCredential, false, void 0, generation, signal); + if (credentialError !== void 0) + throw credentialError; + if (!allowNetwork || signal.aborted) + return; + const credential = await this.resolveRefreshCredential(provider2, storedCredential, signal); + if (!credential) + return; + await this.runProviderRefreshPhase(provider2, credential, true, options.force, generation, signal); + })(); + try { + await raceWithAbortSignal(operation, signal); + } catch (error) { + if (!signal.aborted) { + errors.set(provider2.id, error instanceof Error ? error : new ModelsError("model_source", `Model refresh failed for ${provider2.id}`, { cause: error })); + } + } finally { + if (this.refreshControllers.get(provider2.id) === controller) { + this.refreshControllers.delete(provider2.id); + } + } + })); + try { + await raceWithAbortSignal(refresh, callerSignal); + } catch (error) { + if (!callerSignal.aborted) + throw error; + } + return { aborted: callerSignal.aborted, errors: new Map(errors) }; + } + async resolveRefreshCredential(provider2, stored, signal) { + if (stored?.type === "oauth") { + const oauth = provider2.auth.oauth; + if (!oauth) + return void 0; + if (Date.now() < stored.expires) + return stored; + if (signal.aborted) + return void 0; + return refreshStoredOAuthCredential(this.credentials, provider2.id, oauth, (current) => Date.now() >= current.expires, signal); + } + const apiKey = provider2.auth.apiKey; + if (!apiKey) + return void 0; + const credential = stored?.type === "api_key" ? stored : void 0; + const result = await apiKey.resolve({ ctx: this.authContext, credential, signal }); + if (!result) + return void 0; + return { type: "api_key", key: result.auth.apiKey, env: result.env }; + } + async readCredential(providerId, signal) { + try { + return await this.credentials.read(providerId, { signal }); + } catch (error) { + throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error }); + } + } + async checkProviderAuth(provider2, credential, signal) { + if (credential?.type === "oauth") { + return provider2.auth.oauth ? { source: "OAuth", type: "oauth" } : void 0; + } + const apiKey = provider2.auth.apiKey; + if (!apiKey) + return void 0; + if (apiKey.check) { + try { + return await apiKey.check({ + ctx: this.authContext, + credential: credential?.type === "api_key" ? credential : void 0, + signal + }); + } catch (error) { + throw new ModelsError("auth", `API key auth check failed for provider ${provider2.id}`, { cause: error }); + } + } + const resolution = await resolveProviderAuth(provider2, this.credentials, this.authContext, { signal }); + return resolution ? { source: resolution.source, type: "api_key" } : void 0; + } + checkAuth(providerId, options) { + const signal = operationSignal(options?.signal); + const check = (async () => { + signal.throwIfAborted(); + const provider2 = this.providers.get(providerId); + if (!provider2) + return void 0; + return this.checkProviderAuth(provider2, await this.readCredential(providerId, signal), signal); + })(); + return raceWithAbortSignal(check, signal); + } + async getAuthenticatedProviders(providerId, signal) { + signal.throwIfAborted(); + const providers = providerId ? [this.providers.get(providerId)].filter((entry2) => entry2 !== void 0) : this.getProviders(); + const checks = await Promise.all(providers.map(async (provider2) => { + const credential = await this.readCredential(provider2.id, signal); + return { provider: provider2, credential, auth: await this.checkProviderAuth(provider2, credential, signal) }; + })); + return checks.filter((entry2) => entry2.auth !== void 0); + } + getAvailable(providerId, options) { + const signal = operationSignal(options?.signal); + const available = (async () => { + const providers = await this.getAuthenticatedProviders(providerId, signal); + return providers.flatMap(({ provider: provider2, credential }) => { + const models2 = provider2.getModels(); + return provider2.filterModels?.(models2, credential) ?? models2; + }); + })(); + return raceWithAbortSignal(available, signal); + } + async getAvailableOfType(type, providerId, options) { + return (await this.getAllAvailable(providerId, options)).filter((model2) => isModelType(model2, type)); + } + getAllAvailable(providerId, options) { + const signal = operationSignal(options?.signal); + const available = (async () => { + const providers = await this.getAuthenticatedProviders(providerId, signal); + return providers.flatMap(({ provider: provider2, credential }) => { + const models2 = provider2.getAllModels?.() ?? provider2.getModels(); + if (provider2.filterAllModels) + return provider2.filterAllModels(models2, credential); + if (!provider2.filterModels) + return models2; + const availableChatIds = new Set(provider2.filterModels(provider2.getModels(), credential).map((model2) => model2.id)); + return models2.filter((model2) => !isModelType(model2, "chat") || availableChatIds.has(model2.id)); + }); + })(); + return raceWithAbortSignal(available, signal); + } + async getAuth(providerOrModel, overrides) { + const signal = operationSignal(overrides?.signal); + const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider; + const provider2 = this.providers.get(providerId); + if (!provider2) + return void 0; + const result = await resolveProviderAuth(provider2, this.credentials, this.authContext, { ...overrides, signal }); + if (!result || typeof providerOrModel === "string" || !providerOrModel.headers) + return result; + return { + ...result, + auth: { + ...result.auth, + headers: mergeHeaders(result.auth.headers, providerOrModel.headers) + } + }; + } + async login(providerId, type, interaction, options) { + const signal = operationSignal(interaction.signal); + signal.throwIfAborted(); + const provider2 = this.providers.get(providerId); + if (!provider2) + throw new ModelsError("provider", `Unknown provider: ${providerId}`); + const method = type === "oauth" ? provider2.auth.oauth : provider2.auth.apiKey; + if (!method?.login) { + throw new ModelsError("auth", `${provider2.name} does not support ${type} login`); + } + const loginOperation = method.login({ ...interaction, signal }, options); + const credential = await raceWithAbortSignal(loginOperation, signal); + let mutationStarted = false; + let markMutationStarted; + const started = new Promise((resolve2) => { + markMutationStarted = resolve2; + }); + const mutation = this.credentials.modify(providerId, async () => { + mutationStarted = true; + markMutationStarted?.(); + return credential; + }, { signal }); + void mutation.catch(() => { + }); + try { + await new Promise((resolve2, reject2) => { + const onAbort = () => { + if (!mutationStarted) + reject2(signal.reason); + }; + signal.addEventListener("abort", onAbort, { once: true }); + void Promise.race([started, mutation]).then(() => { + signal.removeEventListener("abort", onAbort); + resolve2(); + }, (error) => { + signal.removeEventListener("abort", onAbort); + reject2(error); + }); + if (signal.aborted) + onAbort(); + }); + await mutation; + } catch (error) { + signal.throwIfAborted(); + throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error }); + } + return credential; + } + async logout(providerId, options) { + const signal = operationSignal(options?.signal); + signal.throwIfAborted(); + try { + await this.credentials.delete(providerId, { signal }); + } catch (error) { + signal.throwIfAborted(); + throw new ModelsError("auth", `Credential store delete failed for ${providerId}`, { cause: error }); + } + } + requireProvider(model2) { + const provider2 = this.providers.get(model2.provider); + if (!provider2) { + throw new ModelsError("provider", `Unknown provider: ${model2.provider}`); + } + return provider2; + } + requireChatProvider(model2) { + assertChatModel(model2); + return this.requireProvider(model2); + } + async applyAuth(model2, options) { + this.requireProvider(model2); + const resolution = await this.getAuth(model2, { + apiKey: options?.apiKey, + env: options?.env, + signal: options?.signal + }); + if (!resolution) { + throw new ModelsError("auth", `Provider is not configured: ${model2.provider}`); + } + const auth = resolution.auth; + const apiKey = options?.apiKey ?? auth.apiKey; + const requestModel = auth.baseUrl ? { ...model2, baseUrl: auth.baseUrl } : model2; + const sessionId = options && "sessionId" in options && typeof options.sessionId === "string" ? options.sessionId : void 0; + let headers = mergeHeaders(mergeHeaders(getOpenCodeSessionHeaders(requestModel, sessionId), auth.headers), options?.headers); + if (options?.transformHeaders) + headers = await options.transformHeaders(headers ?? {}); + const env = resolution.env || options?.env ? { ...resolution.env ?? {}, ...options?.env ?? {} } : void 0; + const identity = mergeClientIdentity(this.identity, options?.identity); + const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {}; + const requestOptions = { + ...providerOptions, + apiKey, + headers, + env, + ...identity === void 0 ? {} : { identity } + }; + return { requestModel, requestOptions }; + } + stream(model2, context, options) { + const transcript = normalizeContext(context); + return lazyStream(model2, async () => { + const provider2 = this.requireChatProvider(model2); + const { requestModel, requestOptions } = await this.applyAuth(model2, options); + return provider2.stream(requestModel, transcript, requestOptions); + }, observeModelRequest(model2, options, false)); + } + async complete(model2, context, options) { + return this.stream(model2, context, options).result(); + } + streamSimple(model2, context, options) { + const transcript = normalizeContext(context); + return lazyStream(model2, async () => { + const provider2 = this.requireChatProvider(model2); + const { requestModel, requestOptions } = await this.applyAuth(model2, options); + return provider2.streamSimple(requestModel, transcript, requestOptions); + }, observeModelRequest(model2, options, false)); + } + async completeSimple(model2, context, options) { + return this.streamSimple(model2, context, options).result(); + } + streamDeferred(model2, handle, options) { + return lazyStream(model2, async () => { + const provider2 = this.requireChatProvider(model2); + if (!provider2.fetchDeferred) { + throw new ModelsError("provider", `Provider ${model2.provider} does not support deferred responses`); + } + const { requestModel, requestOptions } = await this.applyAuth(model2, options); + return provider2.fetchDeferred(requestModel, handle, requestOptions); + }, observeModelRequest(model2, options, true)); + } + async fetchDeferred(model2, handle, options) { + return this.streamDeferred(model2, handle, options).result(); + } + async cancelDeferred(model2, handle, options) { + const provider2 = this.requireChatProvider(model2); + if (!provider2.cancelDeferred) { + throw new ModelsError("provider", `Provider ${model2.provider} does not support deferred responses`); + } + const { requestModel, requestOptions } = await this.applyAuth(model2, options); + await provider2.cancelDeferred(requestModel, handle, requestOptions); + } + async generateImages(model2, context, options) { + try { + assertImageModel(model2); + const provider2 = this.requireProvider(model2); + if (!provider2.generateImages) { + throw new ModelsError("provider", `Provider ${model2.provider} does not support image generation`); + } + const { requestModel, requestOptions } = await this.applyAuth(model2, options); + return await provider2.generateImages(requestModel, context, requestOptions); + } catch (error) { + return imageErrorResult(model2, error, options?.signal?.aborted); + } + } + async classify(model2, context, options) { + try { + assertClassifierModel(model2); + assertClassifierInputSupported(model2, context); + const provider2 = this.requireProvider(model2); + if (!provider2.classify) { + throw new ModelsError("provider", `Provider ${model2.provider} does not support classification`); + } + const { requestModel, requestOptions } = await this.applyAuth(model2, options); + return await provider2.classify(requestModel, context, requestOptions); + } catch (error) { + return classifierErrorResult(model2, error, options?.signal?.aborted); + } + } +}; +function createModels(options) { + return new ModelsImpl(options); +} +function createProvider(input) { + const single = input.api && typeof input.api.stream === "function" ? input.api : void 0; + const byApi = single || !input.api ? void 0 : input.api; + const images = input.images; + const classifiers = input.classifiers; + const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry2) => entry2 !== void 0); + const imageImplementations = Object.values(images ?? {}).filter((entry2) => entry2 !== void 0); + const classifierImplementations = Object.values(classifiers ?? {}).filter((entry2) => entry2 !== void 0); + if (streams.length === 0 && imageImplementations.length === 0 && classifierImplementations.length === 0) { + throw new Error(`Provider ${input.id}: at least one of "api", "images", or "classifiers" is required.`); + } + const baselineModels = input.models; + let dynamicModels = []; + const fetchModels = input.fetchModels; + const currentModels2 = () => { + const merged = [...baselineModels]; + for (const model2 of dynamicModels) { + const index = merged.findIndex((entry2) => getModelType(entry2) === getModelType(model2) && entry2.id === model2.id); + if (index >= 0) + merged[index] = model2; + else + merged.push(model2); + } + return merged; + }; + const apiFor = (model2) => single ?? byApi?.[model2.api]; + const dispatch = (model2, run2) => { + const streams2 = apiFor(model2); + if (!streams2) { + return lazyStream(model2, async () => { + throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model2.api}"`); + }); + } + return run2(streams2); + }; + const provider2 = { + id: input.id, + name: input.name ?? input.id, + baseUrl: input.baseUrl, + headers: input.headers, + auth: input.auth, + getModels: () => currentModels2().filter((model2) => isModelType(model2, "chat")), + getAllModels: currentModels2, + refreshModels: fetchModels ? async (context) => { + if (context.stored) { + const restored = context.stored.models.filter((model2) => model2.provider === input.id).map((model2) => model2); + if (!await context.publish({ + update: () => { + dynamicModels = restored; + } + })) { + return; + } + } + if (!context.allowNetwork || context.signal.aborted) + return; + const fetched = await fetchModels(context); + if (context.signal.aborted) + return; + const refreshed = fetched.filter(hasKnownModelType); + await context.publish({ + persist: { models: refreshed, checkedAt: Date.now() }, + update: () => { + dynamicModels = refreshed; + } + }); + } : void 0, + filterModels: input.filterModels, + filterAllModels: input.filterAllModels, + stream: (model2, context, options) => dispatch(model2, (streams2) => streams2.stream(model2, context, options)), + streamSimple: (model2, context, options) => dispatch(model2, (streams2) => streams2.streamSimple(model2, context, options)) + }; + if (streams.some((entry2) => entry2.fetchDeferred !== void 0)) { + provider2.fetchDeferred = (model2, handle, options) => lazyStream(model2, async () => { + const implementation = apiFor(model2); + if (!implementation?.fetchDeferred) { + throw new ModelsError("provider", `Provider ${input.id} does not support deferred responses for "${model2.api}"`); + } + return implementation.fetchDeferred(model2, handle, options); + }); + } + if (streams.some((entry2) => entry2.cancelDeferred !== void 0)) { + provider2.cancelDeferred = async (model2, handle, options) => { + const implementation = apiFor(model2); + if (!implementation?.cancelDeferred) { + throw new ModelsError("provider", `Provider ${input.id} cannot cancel deferred responses for "${model2.api}"`); + } + await implementation.cancelDeferred(model2, handle, options); + }; + } + if (images && imageImplementations.length > 0) { + provider2.generateImages = async (model2, context, options) => { + const implementation = images[model2.api]; + if (!implementation) { + return imageErrorResult(model2, new ModelsError("provider", `Provider ${input.id} has no image generation implementation for "${model2.api}"`)); + } + return implementation.generateImages(model2, context, options); + }; + } + if (classifiers && classifierImplementations.length > 0) { + provider2.classify = async (model2, context, options) => { + const implementation = classifiers[model2.api]; + if (!implementation) { + return classifierErrorResult(model2, new ModelsError("provider", `Provider ${input.id} has no classifier implementation for "${model2.api}"`)); + } + return implementation.classify(model2, context, options); + }; + } + return provider2; +} + +// node_modules/@earendil-works/pi-ai/dist/utils/json-parse.js +var import_partial_json = __toESM(require_dist(), 1); +var VALID_JSON_ESCAPES = /* @__PURE__ */ new Set(['"', "\\", "/", "b", "f", "n", "r", "t"]); +function isControlCharacter(char) { + const codePoint = char.codePointAt(0); + return codePoint !== void 0 && codePoint >= 0 && codePoint <= 31; +} +function escapeControlCharacter(char) { + switch (char) { + case "\b": + return "\\b"; + case "\f": + return "\\f"; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + case " ": + return "\\t"; + default: + return `\\u${char.codePointAt(0)?.toString(16).padStart(4, "0") ?? "0000"}`; + } +} +function repairJson(json) { + let repaired = ""; + let inString = false; + for (let index = 0; index < json.length; index++) { + const char = json[index]; + if (!inString) { + repaired += char; + if (char === '"') { + inString = true; + } + continue; + } + if (char === '"') { + repaired += char; + inString = false; + continue; + } + if (char === "\\") { + const nextChar = json[index + 1]; + if (nextChar === void 0) { + repaired += "\\\\"; + continue; + } + if (nextChar === "u") { + const unicodeDigits = json.slice(index + 2, index + 6); + if (/^[0-9a-fA-F]{4}$/.test(unicodeDigits)) { + repaired += `\\u${unicodeDigits}`; + index += 5; + continue; + } + } + if (VALID_JSON_ESCAPES.has(nextChar)) { + repaired += `\\${nextChar}`; + index += 1; + continue; + } + repaired += "\\\\"; + continue; + } + repaired += isControlCharacter(char) ? escapeControlCharacter(char) : char; + } + return repaired; +} +function parseJsonWithRepair(json) { + try { + return JSON.parse(json); + } catch (error) { + const repairedJson = repairJson(json); + if (repairedJson !== json) { + return JSON.parse(repairedJson); + } + throw error; + } +} +function parseToolCallArguments(json) { + if (!json?.trim()) + return { arguments: {} }; + try { + const parsed = parseJsonWithRepair(json); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Tool arguments must be a JSON object"); + } + return { arguments: parsed }; + } catch (error) { + return { + arguments: {}, + argumentsError: `${error instanceof Error ? error.message : String(error)} + +Received arguments: +${json}` + }; + } +} + +// node_modules/@earendil-works/pi-ai/dist/utils/streaming-json.js +var MAX_REPORTED_DEPTH = 32; +var ROOT_SLOT = { kind: "root" }; +var CREDIT_PER_APPEND = 256; +var CREDIT_PER_CHARACTER = 8; +var MAX_PENDING_SCALAR_LENGTH = 64; +var NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/; +var NUMBER_WITHOUT_EXPONENT_DIGITS = /^(-?(?:0|[1-9]\d*)(?:\.\d+)?)[eE][+-]?$/; +var LITERALS = [ + ["true", true], + ["false", false], + ["null", null] +]; +var QUOTE = 34; +var BACKSLASH = 92; +var StreamingJsonParser = class { + #root = void 0; + #done = false; + #stack = []; + #string = void 0; + #scalar = ""; + #scalarSlot = void 0; + /** Sum of `size + 1` over the open path: what a fresh snapshot copies. */ + #pathCost = 0; + #credit = 0; + #dirty = false; + #snapshot = void 0; + /** Changes of the piece being read. */ + #changes = []; + /** + * Read the next piece of text; returns the changes it made, in order. Costs time linear in `text.length`. + */ + append(text) { + this.#credit += CREDIT_PER_APPEND + CREDIT_PER_CHARACTER * text.length; + this.#changes = []; + if (this.#string !== void 0) + this.#string.change = void 0; + let index = 0; + while (index < text.length && !this.#done) { + const open = this.#string; + if (open !== void 0) + index = this.#readString(open, text, index); + else if (this.#scalar !== "") + index = this.#readScalar(text, index); + else + this.#structural(text[index++]); + } + return this.#changes; + } + /** + * The value of the text read so far: `undefined` before a value starts, otherwise a plain JSON value that never + * changes afterwards. Returns the previous snapshot while the text read since did not change the value, or while a + * fresh copy of the open path is not yet paid for (see the module comment). + */ + snapshot() { + if (!this.#dirty) + return this.#snapshot; + const pending = this.#pendingScalar(); + const cost = this.#pathCost + (pending === void 0 ? 0 : 1); + if (cost > this.#credit) + return this.#snapshot; + this.#credit -= cost; + this.#dirty = false; + this.#snapshot = this.#copyOpenPath(pending); + return this.#snapshot; + } + /** Read string characters from `start` until the string ends or the text does; returns the next index. */ + #readString(open, text, start) { + let runStart = start; + for (let index = start; index < text.length; index++) { + const code = text.charCodeAt(index); + if (open.escape !== "") { + if (this.#continueEscape(open, text[index])) { + runStart = index + 1; + continue; + } + runStart = index; + } + if (code === QUOTE) { + if (index > runStart) + this.#appendString(open, text.slice(runStart, index)); + this.#endString(open); + return index + 1; + } + if (code === BACKSLASH) { + if (index > runStart) + this.#appendString(open, text.slice(runStart, index)); + open.escape = "\\"; + runStart = index + 1; + } + } + if (text.length > runStart) + this.#appendString(open, text.slice(runStart)); + return text.length; + } + /** Read number or literal characters from `start` up to the next delimiter; returns the delimiter's index. */ + #readScalar(text, start) { + let end = start; + while (end < text.length && !isDelimiter(text[end])) + end++; + if (end > start) { + this.#scalar += text.slice(start, end); + if (this.#scalarSlot !== void 0) + this.#dirty = true; + } + if (end < text.length) + this.#endScalar(); + return end; + } + /** Handle one character outside strings. */ + #structural(char) { + switch (char) { + case " ": + case " ": + case "\n": + case "\r": + return; + case '"': + this.#startString(); + return; + case "{": + case "[": + this.#openContainer(char === "{" ? {} : []); + return; + case "}": + case "]": + this.#closeContainer(); + return; + case ":": { + const frame = this.#stack.at(-1); + if (frame !== void 0 && !Array.isArray(frame.container) && frame.phase === "colon") + frame.phase = "value"; + return; + } + case ",": { + const frame = this.#stack.at(-1); + if (frame !== void 0) + frame.phase = Array.isArray(frame.container) ? "value" : "key"; + return; + } + default: + this.#scalar = char; + this.#scalarSlot = this.#valueSlot(); + if (this.#scalarSlot !== void 0) + this.#dirty = true; + } + } + /** Where a value starting now goes, or `undefined` when this position takes no value. */ + #valueSlot() { + const frame = this.#stack.at(-1); + if (frame === void 0) + return this.#root === void 0 ? ROOT_SLOT : void 0; + if (Array.isArray(frame.container)) + return { kind: "element", index: frame.container.length }; + if ((frame.phase === "value" || frame.phase === "colon") && frame.key !== void 0) { + return { kind: "member", key: frame.key }; + } + return void 0; + } + #startString() { + const frame = this.#stack.at(-1); + if (frame !== void 0 && !Array.isArray(frame.container) && (frame.phase === "key" || frame.phase === "comma")) { + this.#string = { target: "key", slot: void 0, text: "", escape: "", path: void 0, change: void 0 }; + return; + } + const slot = this.#valueSlot(); + const path = this.#reportedPath(slot); + this.#string = { target: "value", slot, text: "", escape: "", path, change: void 0 }; + this.#place(slot, ""); + if (path !== void 0) + this.#string.change = this.#report({ kind: "set", path, value: "" }); + } + /** The path of a value at `slot` in the innermost open container, when its changes are reported. */ + #reportedPath(slot) { + if (slot === void 0) + return void 0; + if (slot.kind === "root") + return []; + const frame = this.#stack.at(-1); + if (!frame.reportsInside) + return void 0; + return [...frame.path, slot.kind === "member" ? slot.key : slot.index]; + } + #report(change) { + this.#changes.push(change); + return change; + } + /** Continue an escape with `char`; returns false when `char` is not part of it and must be read normally. */ + #continueEscape(open, char) { + if (open.escape === "\\") { + const decoded = SIMPLE_ESCAPES[char]; + if (decoded !== void 0) { + open.escape = ""; + this.#appendString(open, decoded); + return true; + } + if (char === "u") { + open.escape = "\\u"; + return true; + } + open.escape = ""; + this.#appendString(open, "\\"); + return false; + } + if (isHexDigit(char)) { + open.escape += char; + if (open.escape.length < 6) + return true; + const decoded = String.fromCharCode(Number.parseInt(open.escape.slice(2), 16)); + open.escape = ""; + this.#appendString(open, decoded); + return true; + } + const literal = open.escape; + open.escape = ""; + this.#appendString(open, literal); + return false; + } + #appendString(open, text) { + open.text += text; + if (open.target !== "value") + return; + this.#place(open.slot, open.text); + if (open.path === void 0) + return; + const change = open.change; + if (change !== void 0 && this.#changes.at(-1) === change) { + if (change.kind === "set") + change.value = change.value + text; + else + change.text += text; + return; + } + open.change = this.#report({ kind: "append", path: open.path, text }); + } + #endString(open) { + this.#string = void 0; + const frame = this.#stack.at(-1); + if (open.target === "key") { + if (frame === void 0) + return; + frame.key = open.text; + frame.phase = "colon"; + return; + } + this.#valueEnded(frame); + } + #endScalar() { + const token = this.#scalar; + const slot = this.#scalarSlot; + this.#scalar = ""; + this.#scalarSlot = void 0; + const value2 = scalarValue(token); + if (value2 !== void 0) { + this.#place(slot, value2); + const path = this.#reportedPath(slot); + if (path !== void 0) + this.#report({ kind: "set", path, value: value2 }); + } + this.#valueEnded(this.#stack.at(-1)); + } + /** The value of the unterminated scalar at the end of the text, if it shows. */ + #pendingScalar() { + const slot = this.#scalarSlot; + const token = this.#scalar; + if (slot === void 0 || token === "" || token.length > MAX_PENDING_SCALAR_LENGTH) + return void 0; + const value2 = pendingScalarValue(token); + return value2 === void 0 ? void 0 : { slot, value: value2 }; + } + #openContainer(container) { + const slot = this.#valueSlot(); + const path = this.#reportedPath(slot); + this.#place(slot, container); + this.#stack.push({ + container, + slot, + path, + reportsInside: path !== void 0 && path.length < MAX_REPORTED_DEPTH, + size: 0, + key: void 0, + phase: Array.isArray(container) ? "value" : "key" + }); + this.#pathCost += 1; + this.#dirty = true; + if (path !== void 0) + this.#report({ kind: "set", path, value: Array.isArray(container) ? [] : {} }); + } + #closeContainer() { + const frame = this.#stack.pop(); + if (frame === void 0) + return; + this.#pathCost -= frame.size + 1; + this.#dirty = true; + if (frame.path !== void 0 && !frame.reportsInside) { + this.#report({ kind: "set", path: frame.path, value: frame.container }); + } + this.#valueEnded(this.#stack.at(-1)); + } + /** A value in `frame`, or the root value when there is no frame, has ended. */ + #valueEnded(frame) { + if (frame === void 0) { + if (this.#root !== void 0) + this.#done = true; + return; + } + frame.phase = "comma"; + } + /** Set the value at `slot` in the open path; nothing for a value in an invalid position. */ + #place(slot, value2) { + if (slot === void 0) + return; + this.#dirty = true; + if (slot.kind === "root") { + this.#root = value2; + return; + } + const frame = this.#stack.at(-1); + if (slot.kind === "element") { + const elements = frame.container; + if (slot.index === elements.length) { + frame.size++; + this.#pathCost++; + } + elements[slot.index] = value2; + return; + } + const members = frame.container; + if (!Object.hasOwn(members, slot.key)) { + frame.size++; + this.#pathCost++; + } + setMember(members, slot.key, value2); + } + /** A fresh snapshot: copies of the open containers, innermost first, each holding the copy of its open child. */ + #copyOpenPath(pending) { + if (this.#stack.length === 0) + return pending?.slot.kind === "root" ? pending.value : this.#root; + let child; + let childSlot; + for (let depth = this.#stack.length - 1; depth >= 0; depth--) { + const frame = this.#stack[depth]; + const copy2 = Array.isArray(frame.container) ? frame.container.slice() : { ...frame.container }; + if (depth === this.#stack.length - 1 && pending !== void 0) + setSlot(copy2, pending.slot, pending.value); + if (child !== void 0 && childSlot !== void 0) + setSlot(copy2, childSlot, child); + child = copy2; + childSlot = frame.slot; + } + return childSlot?.kind === "root" ? child : this.#root; + } +}; +var ToolCallArgumentsStream = class { + #text = ""; + #parser = new StreamingJsonParser(); + #snapshot = void 0; + #arguments = {}; + /** The raw argument text received so far. */ + get text() { + return this.#text; + } + /** Append a piece of argument text; returns the partial arguments, a new object whenever they changed. */ + append(delta) { + this.#text += delta; + this.#parser.append(delta); + return this.#partial(); + } + /** + * Take the provider's complete argument text. When it continues the text so far, returns the missing piece as + * `delta`; otherwise starts over with `text`. Either way returns the partial arguments of `text`. + */ + replace(text) { + if (text.startsWith(this.#text)) { + const delta = text.slice(this.#text.length); + return { delta, arguments: delta === "" ? this.#partial() : this.append(delta) }; + } + this.#text = ""; + this.#parser = new StreamingJsonParser(); + return { delta: void 0, arguments: this.append(text) }; + } + /** The final arguments: one full parse of the complete text, which reports malformed arguments. */ + finish() { + return parseToolCallArguments(this.#text); + } + #partial() { + const snapshot = this.#parser.snapshot(); + if (snapshot === this.#snapshot) + return this.#arguments; + this.#snapshot = snapshot; + this.#arguments = typeof snapshot === "object" && snapshot !== null && !Array.isArray(snapshot) ? snapshot : {}; + return this.#arguments; + } +}; +var SIMPLE_ESCAPES = { + '"': '"', + "\\": "\\", + "/": "/", + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: " " +}; +function isDelimiter(char) { + return char === " " || char === " " || char === "\n" || char === "\r" || char === "," || char === ":" || char === "{" || char === "}" || char === "[" || char === "]" || char === '"'; +} +function isHexDigit(char) { + return char >= "0" && char <= "9" || char >= "a" && char <= "f" || char >= "A" && char <= "F"; +} +function scalarValue(token) { + for (const [literal, value2] of LITERALS) + if (token === literal) + return value2; + return NUMBER.test(token) ? Number(token) : void 0; +} +function pendingScalarValue(token) { + for (const [literal, value2] of LITERALS) + if (literal.startsWith(token)) + return value2; + if (NUMBER.test(token)) + return Number(token); + const mantissa = NUMBER_WITHOUT_EXPONENT_DIGITS.exec(token); + return mantissa === null ? void 0 : Number(mantissa[1]); +} +function setSlot(container, slot, value2) { + if (slot.kind === "element") + container[slot.index] = value2; + else if (slot.kind === "member") + setMember(container, slot.key, value2); +} +function setMember(members, key, value2) { + if (key === "__proto__") { + Object.defineProperty(members, key, { value: value2, writable: true, enumerable: true, configurable: true }); + } else { + members[key] = value2; + } +} + +// node_modules/@earendil-works/pi-ai/dist/providers/faux.js +var DEFAULT_API = "faux"; +var DEFAULT_PROVIDER = "faux"; +var DEFAULT_MODEL_ID = "faux-1"; +var DEFAULT_MODEL_NAME = "Faux Model"; +var DEFAULT_BASE_URL = "http://localhost:0"; +var DEFAULT_MIN_TOKEN_SIZE = 3; +var DEFAULT_MAX_TOKEN_SIZE = 5; +var DEFAULT_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } +}; +function fauxText(text) { + return { type: "text", text }; +} +function normalizeFauxAssistantContent(content) { + if (typeof content === "string") { + return [fauxText(content)]; + } + return Array.isArray(content) ? content : [content]; +} +function fauxAssistantMessage(content, options = {}) { + return { + role: "assistant", + content: normalizeFauxAssistantContent(content), + api: DEFAULT_API, + provider: DEFAULT_PROVIDER, + model: DEFAULT_MODEL_ID, + usage: DEFAULT_USAGE, + stopReason: options.stopReason ?? "stop", + ...options.deferred === void 0 ? {} : { deferred: options.deferred }, + ...options.errorMessage === void 0 ? {} : { errorMessage: options.errorMessage }, + ...options.responseId === void 0 ? {} : { responseId: options.responseId }, + timestamp: options.timestamp ?? Date.now() + }; +} +function estimateTokens(text) { + return Math.ceil(text.length / 4); +} +function randomId(prefix) { + return `${prefix}:${Date.now()}:${Math.random().toString(36).slice(2)}`; +} +function contentToText(content) { + if (typeof content === "string") { + return content; + } + return content.map((block) => { + if (block.type === "text") { + return block.text; + } + return `[image:${block.mimeType}:${block.data.length}]`; + }).join("\n"); +} +function assistantContentToText(content) { + return content.map((block) => { + if (block.type === "text") { + return block.text; + } + if (block.type === "thinking") { + return block.thinking; + } + return `${block.name}:${JSON.stringify(block.arguments)}`; + }).join("\n"); +} +function toolResultToText(message) { + return [message.toolName, ...message.content.map((block) => contentToText([block]))].join("\n"); +} +function messageToText(message) { + if (message.role === "system") { + return [ + getSystemMessageText(message), + ...message.toolsRemoved?.map((tool) => `tool-:${JSON.stringify(tool)}`) ?? [], + ...message.toolsAdded?.map((tool) => `tool+:${JSON.stringify(tool)}`) ?? [] + ].filter((part) => part.length > 0).join("\n"); + } + if (message.role === "user") { + return contentToText(message.content); + } + if (message.role === "assistant") { + return assistantContentToText(message.content); + } + return toolResultToText(message); +} +function joinedLength(messages, count = messages.length) { + let length = count > 0 ? (count - 1) * 2 : 0; + for (let index = 0; index < count; index++) + length += messages[index].length; + return length; +} +function commonPromptPrefixLength(previous, current) { + let index = 0; + while (index < previous.length && index < current.length && previous[index] === current[index]) + index++; + const rest = (messages) => index === messages.length ? "" : (index > 0 ? "\n\n" : "") + messages.slice(index).join("\n\n"); + return joinedLength(previous, index) + commonPrefixLength(rest(previous), rest(current)); +} +function commonPrefixLength(a, b) { + const length = Math.min(a.length, b.length); + let index = 0; + while (index < length && a[index] === b[index]) { + index++; + } + return index; +} +function withUsageEstimate(message, context, options, promptCache) { + const prompt = context.messages.map((message2) => `${message2.role}:${messageToText(message2)}`); + const promptLength = joinedLength(prompt); + const promptTokens = Math.ceil(promptLength / 4); + const outputTokens = estimateTokens(assistantContentToText(message.content)); + let input = promptTokens; + let cacheRead = 0; + let cacheWrite = 0; + const sessionId = options?.sessionId; + if (sessionId && options?.cacheRetention !== "none") { + const previousPrompt = promptCache.get(sessionId); + if (previousPrompt) { + const cachedChars = commonPromptPrefixLength(previousPrompt, prompt); + cacheRead = Math.ceil(cachedChars / 4); + cacheWrite = Math.max(0, promptTokens - cacheRead); + } else { + cacheWrite = promptTokens; + } + input = 0; + promptCache.set(sessionId, prompt); + } + return { + ...message, + usage: { + input, + output: outputTokens, + cacheRead, + cacheWrite, + totalTokens: input + outputTokens + cacheRead + cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } + } + }; +} +function splitStringByTokenSize(text, minTokenSize, maxTokenSize) { + const chunks = []; + let index = 0; + while (index < text.length) { + const tokenSize = minTokenSize + Math.floor(Math.random() * (maxTokenSize - minTokenSize + 1)); + const charSize = Math.max(1, tokenSize * 4); + chunks.push(text.slice(index, index + charSize)); + index += charSize; + } + return chunks.length > 0 ? chunks : [""]; +} +function cloneMessage(message, api, provider2, modelId) { + const cloned = structuredClone(message); + return { + ...cloned, + api, + provider: provider2, + model: modelId, + timestamp: cloned.timestamp ?? Date.now(), + usage: cloned.usage ?? DEFAULT_USAGE + }; +} +function createDeferredMessage(model2, handle) { + return { + role: "assistant", + content: [], + api: model2.api, + provider: model2.provider, + model: model2.id, + usage: DEFAULT_USAGE, + stopReason: "deferred", + deferred: handle, + timestamp: Date.now() + }; +} +function createErrorMessage(error, api, provider2, modelId) { + return { + role: "assistant", + content: [], + api, + provider: provider2, + model: modelId, + usage: DEFAULT_USAGE, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now() + }; +} +function createAbortedMessage(partial) { + return { + ...partial, + stopReason: "aborted", + errorMessage: "Request was aborted", + timestamp: Date.now() + }; +} +function scheduleChunk(chunk, tokensPerSecond) { + if (!tokensPerSecond || tokensPerSecond <= 0) { + return new Promise((resolve2) => queueMicrotask(resolve2)); + } + const delayMs = estimateTokens(chunk) / tokensPerSecond * 1e3; + return new Promise((resolve2) => setTimeout(resolve2, delayMs)); +} +async function streamWithDeltas(stream, message, minTokenSize, maxTokenSize, tokensPerSecond, signal) { + const partial = { ...message, content: [], stopReason: "pending" }; + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + stream.push({ type: "start", partial: { ...partial } }); + for (let index = 0; index < message.content.length; index++) { + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + const block = message.content[index]; + if (block.type === "thinking") { + partial.content = [...partial.content, { type: "thinking", thinking: "" }]; + stream.push({ type: "thinking_start", contentIndex: index, partial: { ...partial } }); + for (const chunk of splitStringByTokenSize(block.thinking, minTokenSize, maxTokenSize)) { + await scheduleChunk(chunk, tokensPerSecond); + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + partial.content[index].thinking += chunk; + stream.push({ type: "thinking_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); + } + stream.push({ + type: "thinking_end", + contentIndex: index, + content: block.thinking, + partial: { ...partial } + }); + continue; + } + if (block.type === "text") { + partial.content = [...partial.content, { type: "text", text: "" }]; + stream.push({ type: "text_start", contentIndex: index, partial: { ...partial } }); + for (const chunk of splitStringByTokenSize(block.text, minTokenSize, maxTokenSize)) { + await scheduleChunk(chunk, tokensPerSecond); + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + partial.content[index].text += chunk; + stream.push({ type: "text_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); + } + stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: { ...partial } }); + continue; + } + partial.content = [...partial.content, { type: "toolCall", id: block.id, name: block.name, arguments: {} }]; + stream.push({ type: "toolcall_start", contentIndex: index, partial: { ...partial } }); + const toolArguments = new ToolCallArgumentsStream(); + for (const chunk of splitStringByTokenSize(JSON.stringify(block.arguments), minTokenSize, maxTokenSize)) { + await scheduleChunk(chunk, tokensPerSecond); + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + partial.content[index].arguments = toolArguments.append(chunk); + stream.push({ type: "toolcall_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); + } + partial.content[index].arguments = block.arguments; + stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: { ...partial } }); + } + if (message.stopReason === "pending") { + throw new Error("Faux response ended without a stop reason"); + } + if (message.stopReason === "error" || message.stopReason === "aborted") { + stream.push({ type: "error", reason: message.stopReason, error: message }); + stream.end(message); + return; + } + stream.push({ type: "done", reason: message.stopReason, message }); + stream.end(message); +} +function createFauxCore(options) { + const api = options.api ?? randomId(DEFAULT_API); + const provider2 = options.provider ?? DEFAULT_PROVIDER; + const minTokenSize = Math.max(1, Math.min(options.tokenSize?.min ?? DEFAULT_MIN_TOKEN_SIZE, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE)); + const maxTokenSize = Math.max(minTokenSize, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE); + let pendingResponses = []; + const tokensPerSecond = options.tokensPerSecond; + const state = { callCount: 0, deferredFetchCount: 0, cancelledDeferred: [] }; + const promptCache = /* @__PURE__ */ new Map(); + const deferredResponses = /* @__PURE__ */ new Map(); + const modelDefinitions = options.models?.length ? options.models : [ + { + id: DEFAULT_MODEL_ID, + name: DEFAULT_MODEL_NAME, + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128e3, + maxTokens: 16384 + } + ]; + const models2 = modelDefinitions.map((definition) => ({ + id: definition.id, + name: definition.name ?? definition.id, + api, + provider: provider2, + baseUrl: DEFAULT_BASE_URL, + reasoning: definition.reasoning ?? false, + input: definition.input ?? ["text", "image"], + inputLimits: definition.inputLimits, + cost: definition.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: definition.contextWindow ?? 128e3, + maxTokens: definition.maxTokens ?? 16384 + })); + const resolveResponse = async (step, context, streamOptions, requestModel) => { + const resolved = typeof step === "function" ? await step(context, streamOptions, state, requestModel) : step; + return withUsageEstimate(cloneMessage(resolved, api, provider2, requestModel.id), context, streamOptions, promptCache); + }; + const stream = (requestModel, context, streamOptions) => { + const outer = createAssistantMessageEventStream(); + const step = pendingResponses.shift(); + state.callCount++; + queueMicrotask(async () => { + try { + await streamOptions?.onResponse?.({ status: 200, headers: {} }, requestModel); + if (!step) { + let message2 = createErrorMessage(new Error("No more faux responses queued"), api, provider2, requestModel.id); + message2 = withUsageEstimate(message2, context, streamOptions, promptCache); + outer.push({ type: "error", reason: "error", error: message2 }); + outer.end(message2); + return; + } + if (streamOptions?.deferred) { + const handle = { + provider: requestModel.provider, + modelId: requestModel.id, + api: requestModel.api, + id: randomId("deferred"), + ...options.deferred?.pollAfterMs !== void 0 ? { pollAfterMs: options.deferred.pollAfterMs } : {} + }; + deferredResponses.set(handle.id, { + handle, + step, + context, + options: streamOptions, + model: requestModel, + pendingFetches: Math.max(0, Math.floor(options.deferred?.pendingFetches ?? 0)), + cancelled: false + }); + await streamWithDeltas(outer, createDeferredMessage(requestModel, handle), minTokenSize, maxTokenSize, tokensPerSecond, streamOptions.signal); + return; + } + const message = await resolveResponse(step, context, streamOptions, requestModel); + await streamWithDeltas(outer, message, minTokenSize, maxTokenSize, tokensPerSecond, streamOptions?.signal); + } catch (error) { + const message = createErrorMessage(error, api, provider2, requestModel.id); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + } + }); + return outer; + }; + const streamSimple = (streamModel, context, streamOptions) => stream(streamModel, context, streamOptions); + const fetchDeferred = (requestModel, handle, fetchOptions) => { + const outer = createAssistantMessageEventStream(); + state.deferredFetchCount++; + queueMicrotask(async () => { + try { + await fetchOptions?.onResponse?.({ status: 200, headers: {} }, requestModel); + const entry2 = deferredResponses.get(handle.id); + if (!entry2 || entry2.handle.provider !== handle.provider || entry2.handle.modelId !== handle.modelId || entry2.handle.api !== handle.api) { + throw new Error(`Unknown faux deferred response: ${handle.id}`); + } + if (entry2.cancelled) + throw new Error(`Faux deferred response was cancelled: ${handle.id}`); + if (entry2.pendingFetches > 0) { + entry2.pendingFetches--; + await streamWithDeltas(outer, createDeferredMessage(requestModel, entry2.handle), minTokenSize, maxTokenSize, tokensPerSecond, fetchOptions?.signal); + return; + } + if (!entry2.final) { + const { deferred: _deferred, signal: _submissionSignal, onResponse: _submissionOnResponse, ...submissionOptions } = entry2.options ?? {}; + try { + entry2.final = await resolveResponse(entry2.step, entry2.context, submissionOptions, entry2.model); + } catch (error) { + entry2.final = createErrorMessage(error, api, provider2, entry2.model.id); + } + } + await streamWithDeltas(outer, entry2.final, minTokenSize, maxTokenSize, tokensPerSecond, fetchOptions?.signal); + } catch (error) { + const message = createErrorMessage(error, api, provider2, requestModel.id); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + } + }); + return outer; + }; + const cancelDeferred2 = async (requestModel, handle, cancelOptions) => { + state.cancelledDeferred.push(structuredClone(handle)); + const entry2 = deferredResponses.get(handle.id); + if (entry2) + entry2.cancelled = true; + await cancelOptions?.onResponse?.({ status: 200, headers: {} }, requestModel); + }; + function getModel(requestedModelId) { + if (!requestedModelId) { + return models2[0]; + } + return models2.find((candidate) => candidate.id === requestedModelId); + } + return { + api, + provider: provider2, + models: models2, + stream, + streamSimple, + fetchDeferred, + cancelDeferred: cancelDeferred2, + getModel, + state, + setResponses(responses) { + pendingResponses = [...responses]; + }, + appendResponses(responses) { + pendingResponses.push(...responses); + }, + getPendingResponseCount() { + return pendingResponses.length; + } + }; +} +function fauxProvider(options = {}) { + const core = createFauxCore(options); + const provider2 = createProvider({ + id: core.provider, + auth: { apiKey: { name: "Faux", resolve: async () => ({ auth: {} }) } }, + models: core.models, + api: { + stream: core.stream, + streamSimple: core.streamSimple, + fetchDeferred: core.fetchDeferred, + cancelDeferred: core.cancelDeferred + } + }); + return { + provider: provider2, + api: core.api, + models: core.models, + getModel: core.getModel, + state: core.state, + setResponses: core.setResponses, + appendResponses: core.appendResponses, + getPendingResponseCount: core.getPendingResponseCount + }; +} + +// node_modules/@earendil-works/pi-schema/dist/resolver.js +var DEFAULT_BASE = "https://json-schema.invalid/root.json"; +var Resolver = class { + draft; + rootBase; + /** Schema object to the base URI it was indexed under. */ + baseOf = /* @__PURE__ */ new Map(); + /** URI (with a `#name` fragment) of every `$dynamicAnchor`, to its schema. */ + dynamicAnchors = /* @__PURE__ */ new Map(); + root; + options; + registry = /* @__PURE__ */ new Map(); + rejectUnresolved; + /** The references that named nothing and resolved to `false` (with `unresolvedRefs: "reject"`). */ + unresolved = /* @__PURE__ */ new Set(); + resources = /* @__PURE__ */ new Map(); + anchors = /* @__PURE__ */ new Map(); + indexed = false; + constructor(root2, options) { + this.draft = options.draft === "7" ? "7" : "2020-12"; + this.root = root2; + this.options = options; + this.rejectUnresolved = options.unresolvedRefs === "reject"; + this.rootBase = isSchemaObject(root2) ? this.baseFor(root2, DEFAULT_BASE) : DEFAULT_BASE; + } + /** + * The base URI of `node`, whose parent has `base`: its `$id` changes it, unless draft 7 makes the `$id` inert. Once + * the schema is indexed, the indexed base (a resolved reference target already has its own). + */ + baseFor(node, base) { + const indexed = this.baseOf.get(node); + if (indexed !== void 0) + return indexed; + const id = node.$id; + if (typeof id !== "string") + return base; + if (this.draft === "7" && (typeof node.$ref === "string" || id.startsWith("#"))) + return base; + return documentOf(resolveUri(id, base)); + } + /** Indexes the root schema and the registered documents' URIs (once). */ + ensureIndexed() { + if (this.indexed) + return; + this.indexed = true; + for (const [uri, schema] of Object.entries(this.options.schemas ?? {})) { + this.registry.set(documentOf(resolveUri(uri, DEFAULT_BASE)), schema); + } + this.resources.set(DEFAULT_BASE, this.root); + this.index(this.root, DEFAULT_BASE); + } + /** + * The schema a reference names, with the base URI it compiles under. Throws if it names nothing, or, with + * `unresolvedRefs: "reject"`, resolves to `false`, the schema no value passes. + */ + resolve(ref, base) { + this.ensureIndexed(); + if (!this.rejectUnresolved) + return this.find(ref, base); + try { + return this.find(ref, base); + } catch { + this.unresolved.add(resolveUri(ref, base)); + return { node: false, base }; + } + } + /** Whether `ref`, from `base`, named nothing and resolved to `false`. */ + isUnresolved(ref, base) { + return this.unresolved.has(resolveUri(ref, base)); + } + find(ref, base) { + const uri = resolveUri(ref, base); + const doc = documentOf(uri); + const fragment = uri.length > doc.length ? decodeURIComponent(uri.slice(doc.length + 1)) : ""; + const root2 = this.resources.has(doc) ? this.resources.get(doc) : this.load(doc); + if (root2 === void 0) + throw new Error(`Unresolved $ref ${ref} (no schema registered for ${doc})`); + if (fragment === "") + return { node: root2, base: doc }; + if (fragment.startsWith("/")) + return this.pointer(root2, doc, fragment); + const target = this.anchors.get(`${doc}#${fragment}`); + if (target === void 0) + throw new Error(`Unresolved $ref ${ref} (no anchor ${fragment} in ${doc})`); + return { node: target, base: this.baseOf.get(target) ?? doc }; + } + /** Whether `node` is the root of the schema resource at `base`. */ + isResource(node, base) { + this.ensureIndexed(); + return this.resources.get(base) === node; + } + // Walks only keywords that hold subschemas: an `$id` inside `enum`, `const` or an unknown keyword names nothing. + index(node, base) { + if (!isSchemaObject(node) || this.baseOf.has(node)) + return; + let b = base; + const refOnly = this.draft === "7" && typeof node.$ref === "string"; + if (!refOnly && typeof node.$id === "string") { + if (this.draft === "7" && node.$id.startsWith("#")) + this.anchors.set(documentOf(b) + node.$id, node); + else { + const uri = resolveUri(node.$id, b); + b = documentOf(uri); + this.resources.set(b, node); + if (uri.length > b.length + 1) + this.anchors.set(uri, node); + } + } + this.baseOf.set(node, b); + if (refOnly) + return; + if (this.draft !== "7") { + if (typeof node.$anchor === "string") + this.anchors.set(`${b}#${node.$anchor}`, node); + if (typeof node.$dynamicAnchor === "string") { + this.anchors.set(`${b}#${node.$dynamicAnchor}`, node); + this.dynamicAnchors.set(`${b}#${node.$dynamicAnchor}`, node); + } + } + for (const key of SCHEMA_KEYWORDS) + this.index(node[key], b); + for (const key of MAP_KEYWORDS) { + const map = node[key]; + if (isSchemaObject(map)) + for (const k of Object.keys(map)) + this.index(map[k], b); + } + for (const key of ARRAY_KEYWORDS) { + const list = node[key]; + if (Array.isArray(list)) + for (const s of list) + this.index(s, b); + } + } + load(doc) { + const schema = this.registry.get(doc); + if (schema === void 0) + return void 0; + this.resources.set(doc, schema); + this.index(schema, doc); + return schema; + } + pointer(root2, base, fragment) { + let node = root2; + let b = base; + for (const raw of fragment.split("/").slice(1)) { + const key = raw.replace(/~1/g, "/").replace(/~0/g, "~"); + node = typeof node === "object" && node !== null && Object.hasOwn(node, key) ? node[key] : void 0; + if (node === void 0) + throw new Error(`Unresolved $ref #${fragment} in ${base}`); + if (typeof node === "object" && node !== null) + b = this.baseOf.get(node) ?? b; + } + return { node, base: b }; + } +}; +var SCHEMA_KEYWORDS = [ + "additionalProperties", + "propertyNames", + "contains", + "not", + "if", + "then", + "else", + "unevaluatedProperties", + "unevaluatedItems", + "additionalItems", + "contentSchema", + "items" +]; +var MAP_KEYWORDS = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"]; +var ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems", "items"]; +function isSchemaObject(value2) { + return typeof value2 === "object" && value2 !== null && !Array.isArray(value2); +} +function resolveUri(ref, base) { + try { + return new URL(ref, base).href; + } catch { + return ref.startsWith("#") ? documentOf(base) + ref : ref; + } +} +function documentOf(uri) { + const hash = uri.indexOf("#"); + return hash < 0 ? uri : uri.slice(0, hash); +} + +// node_modules/@earendil-works/pi-schema/dist/shared.js +function sharedRegex(source) { + const hit = regexes.get(source)?.deref(); + if (hit !== void 0) + return hit; + let re; + try { + re = new RegExp(source, "u"); + } catch { + re = new RegExp(source); + } + regexes.set(source, new WeakRef(re)); + regexCleanup.register(re, source); + return re; +} +var regexes = /* @__PURE__ */ new Map(); +var regexCleanup = new FinalizationRegistry((source) => { + if (regexes.get(source)?.deref() === void 0) + regexes.delete(source); +}); + +// node_modules/@earendil-works/pi-schema/dist/coerce.js +var Coercer = class { + c; + schema; + /** + * The (schema node, value) pairs pass 2 is converting through a re-entry point, as a stack. Conversion can enter + * a node again with the same value only through a `$ref` or by wrapping a single value into an array (also for + * schemas that cycle through shared JS objects): a recursive schema that meets a value no branch accepts would + * convert it without end. A pair already on the stack (primitives by value, objects by reference) leaves the value + * unchanged. Only those two points push, so schemas without references or wraps pay nothing. + */ + entered = []; + /** How often a re-entry was cut short in this coerce() call; results computed across one are not memoized. */ + cuts = 0; + /** + * Results of `$ref` targets by (node, value) within one coerce() call: a recursive union whose branches all + * convert the same children (and all fail) would convert them once per branch, exponentially in the depth. + */ + memo = void 0; + constructor(compiler2, schema) { + this.c = compiler2; + this.schema = schema; + } + /** `value` coerced: a copy along the changed paths only, or `value` itself when nothing changes. */ + coerce(value2) { + const base = this.c.r.rootBase; + const withoutNulls = this.dropOptionalNulls(value2, this.schema, base); + return this.coerceTypes(this.convertOnly(withoutNulls), this.schema, base); + } + /** Pass 2 alone, as TypeBox's `Value.Convert` (the TypeBox conformance tests run it). */ + convertOnly(value2) { + try { + return this.convert(this.schema, this.c.r.rootBase, value2); + } finally { + this.memo = void 0; + this.cuts = 0; + } + } + /** `node` converted for `value` at a re-entry point: unchanged when that pair is already being converted. */ + reenter(node, base, value2, memoize) { + const entered = this.entered; + for (let i = 0; i < entered.length; i += 2) { + if (entered[i] === node && Object.is(entered[i + 1], value2)) { + this.cuts++; + return value2; + } + } + const memo = memoize ? this.memo?.get(node) : void 0; + if (memo?.has(value2)) + return memo.get(value2); + const cuts = this.cuts; + entered.push(node, value2); + let result; + try { + result = this.convert(node, base, value2); + } finally { + entered.length -= 2; + } + if (memoize && this.cuts === cuts) { + this.memo ??= /* @__PURE__ */ new Map(); + let values = this.memo.get(node); + if (values === void 0) { + values = /* @__PURE__ */ new Map(); + this.memo.set(node, values); + } + values.set(value2, result); + } + return result; + } + /** Whether `value` passes the schema `node`; undefined when `node` cannot be compiled. */ + passes(node, base, value2) { + try { + return this.c.check(node, this.baseOf(node, base))(value2); + } catch { + return void 0; + } + } + baseOf(node, base) { + return isSchemaObject(node) ? this.c.r.baseOf.get(node) ?? base : base; + } + // --------------------------------------------------------------------------------------------------------------- + // Pass 1: optional nulls + // --------------------------------------------------------------------------------------------------------------- + dropOptionalNulls(value2, schema, base) { + if (!isSchemaObject(schema)) + return value2; + const b = this.baseOf(schema, base); + if (Array.isArray(value2)) { + return mapItems(value2, (item, i) => { + const itemSchema = itemSchemaAt(schema, i); + return itemSchema === void 0 ? item : this.dropOptionalNulls(item, itemSchema, b); + }); + } + if (!isPlainObject(value2)) + return value2; + const properties = isSchemaObject(schema.properties) ? schema.properties : {}; + const patterns = isSchemaObject(schema.patternProperties) ? Object.entries(schema.patternProperties).map(([pattern, item]) => [sharedRegex(pattern), item]) : []; + const additional = isSchemaObject(schema.additionalProperties) ? schema.additionalProperties : void 0; + if (patterns.length === 0 && additional === void 0 && !isSchemaObject(schema.properties)) + return value2; + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + let out; + const set = (key, next) => { + if (next === (out ?? value2)[key]) + return; + out ??= { ...value2 }; + out[key] = next; + }; + for (const [key, property] of Object.entries(properties)) { + if (!Object.hasOwn(value2, key)) + continue; + const isRef = isSchemaObject(property) && typeof property.$ref === "string"; + if (value2[key] === null && !required.has(key) && !isRef && this.passes(property, b, null) === false) { + out ??= { ...value2 }; + delete out[key]; + continue; + } + set(key, this.dropOptionalNulls(value2[key], property, b)); + } + if (patterns.length > 0 || additional !== void 0) { + for (const key of Object.keys(value2)) { + let matched = Object.hasOwn(properties, key); + for (const [pattern, item] of patterns) { + if (!pattern.test(key)) + continue; + matched = true; + set(key, this.dropOptionalNulls((out ?? value2)[key], item, b)); + } + if (!matched && additional !== void 0) + set(key, this.dropOptionalNulls(value2[key], additional, b)); + } + } + return out ?? value2; + } + // --------------------------------------------------------------------------------------------------------------- + // Pass 2: TypeBox's conversion rules + // --------------------------------------------------------------------------------------------------------------- + convert(node, base, value2) { + if (!isSchemaObject(node)) + return value2; + const b = this.baseOf(node, base); + if (typeof node.$ref === "string") { + let target; + try { + target = this.c.r.resolve(node.$ref, b); + } catch { + return value2; + } + return isSchemaObject(target.node) ? this.reenter(target.node, target.base, value2, true) : value2; + } + return this.convertNode(node, b, value2); + } + /** One schema node's conversion: its first keyword among `const`, `enum`, the unions, `allOf` and `type`. */ + convertNode(node, b, value2) { + if (Object.hasOwn(node, "const")) + return convertLiteral(node.const, value2); + if (Array.isArray(node.enum)) + return this.convertEnum(node, node.enum, b, value2); + if (Array.isArray(node.anyOf)) + return this.convertUnion(node, node.anyOf, b, value2); + if (Array.isArray(node.oneOf)) + return this.convertUnion(node, node.oneOf, b, value2); + if (Array.isArray(node.allOf)) { + let next = value2; + for (const member of node.allOf) + next = this.convert(member, b, next); + return this.convertMembers(node, b, next); + } + switch (node.type) { + case "string": + return orValue(tryString(value2), value2); + case "number": + return orValue(tryNumber(value2), value2); + case "integer": { + const result = tryNumber(value2); + return result === void 0 ? value2 : Math.trunc(result.value); + } + case "boolean": + return orValue(tryBoolean(value2), value2); + case "null": + return orValue(tryNull(value2), value2); + case "array": + return this.convertArray(node, b, value2); + case "object": + return this.convertObject(node, b, value2); + case void 0: + if (node.properties !== void 0 || node.patternProperties !== void 0) { + return this.convertObject(node, b, value2); + } + if (node.items !== void 0 || node.prefixItems !== void 0) + return this.convertArray(node, b, value2); + return value2; + default: + return value2; + } + } + /** An enum is a union of its values: a member stays, else the first value the input converts to. */ + convertEnum(node, values, base, value2) { + if (values.some((member) => member === value2)) + return value2; + for (const member of values) { + const candidate = convertLiteral(member, value2); + if (candidate !== value2 && this.passes(node, base, candidate) === true) + return candidate; + } + return value2; + } + /** + * A value one branch accepts stays; else the first branch's conversion that the whole union accepts. Branches are + * converted in order and only until one is accepted, so a later recursive branch is not entered needlessly. A node + * that also declares members (plain JSON Schema; TypeBox's never do) converts them into each candidate before it is + * checked, so a branch is not discarded for a sibling property that is converted after it. + */ + convertUnion(node, branches, base, value2) { + if (branches.some((branch) => this.passes(branch, base, value2) === true)) + return this.convertMembers(node, base, value2); + for (const branch of branches) { + const candidate = this.convertMembers(node, base, this.convert(branch, base, value2)); + if (this.passes(node, base, candidate) === true) + return candidate; + } + return this.convertMembers(node, base, value2); + } + /** The object members of a union or intersection node, when it declares any. */ + convertMembers(node, base, value2) { + const declares = node.properties !== void 0 || node.patternProperties !== void 0 || node.additionalProperties !== void 0; + return declares ? this.convertObject(node, base, value2) : value2; + } + convertArray(node, base, value2) { + const prefix = Array.isArray(node.items) ? node.items : Array.isArray(node.prefixItems) ? node.prefixItems : void 0; + if (prefix !== void 0) { + if (!Array.isArray(value2)) + return value2; + const rest = Array.isArray(node.items) ? void 0 : node.items; + return mapItems(value2, (item2, i) => { + const schema = i < prefix.length ? prefix[i] : rest; + return schema === void 0 ? item2 : this.convert(schema, base, item2); + }); + } + if (Array.isArray(value2)) { + return node.items === void 0 ? value2 : mapItems(value2, (item2) => this.convert(node.items, base, item2)); + } + const item = node.items; + return [isSchemaObject(item) ? this.reenter(item, base, value2, false) : value2]; + } + convertObject(node, base, value2) { + if (!isPlainObject(value2)) + return value2; + const properties = isSchemaObject(node.properties) ? node.properties : {}; + const patterns = isSchemaObject(node.patternProperties) ? Object.entries(node.patternProperties).map(([pattern, schema]) => [sharedRegex(pattern), schema]) : []; + const additional = isSchemaObject(node.additionalProperties) ? node.additionalProperties : void 0; + let out; + for (const key of Object.keys(value2)) { + if (value2[key] === void 0) + continue; + let next = value2[key]; + let matched = Object.hasOwn(properties, key); + if (matched) + next = this.convert(properties[key], base, next); + for (const [pattern, schema] of patterns) { + if (!pattern.test(key)) + continue; + matched = true; + next = this.convert(schema, base, next); + } + if (!matched && additional !== void 0) + next = this.convert(additional, base, next); + if (next !== value2[key]) { + out ??= { ...value2 }; + out[key] = next; + } + } + return out ?? value2; + } + // --------------------------------------------------------------------------------------------------------------- + // Pass 3: primitive coercion by type + // --------------------------------------------------------------------------------------------------------------- + coerceTypes(value2, schema, base) { + if (!isSchemaObject(schema)) + return value2; + const b = this.baseOf(schema, base); + let next = value2; + if (Array.isArray(schema.allOf)) + for (const member of schema.allOf) + next = this.coerceTypes(next, member, b); + if (Array.isArray(schema.anyOf)) + next = this.coerceUnion(next, schema.anyOf, b); + if (Array.isArray(schema.oneOf)) + next = this.coerceUnion(next, schema.oneOf, b); + const types = typesOf(schema); + const matchesUnionMember = types.length > 1 && types.some((type) => matchesType(next, type)); + if (types.length > 0 && !matchesUnionMember) { + for (const type of types) { + const candidate = coercePrimitive(next, type); + if (candidate !== next) { + next = candidate; + break; + } + } + } + if (types.includes("object") && isPlainObject(next)) + next = this.coerceProperties(next, schema, b); + if (types.includes("array") && Array.isArray(next)) + next = this.coerceItems(next, schema, b); + return next; + } + coerceUnion(value2, branches, base) { + for (const branch of branches) + if (this.passes(branch, base, value2) === true) + return value2; + for (const branch of branches) { + const coerced = this.coerceTypes(value2, branch, base); + if (this.passes(branch, base, coerced) === true) + return coerced; + } + return value2; + } + coerceProperties(value2, schema, base) { + const properties = isSchemaObject(schema.properties) ? schema.properties : {}; + const patterns = isSchemaObject(schema.patternProperties) ? Object.entries(schema.patternProperties).map(([pattern, item]) => [sharedRegex(pattern), item]) : []; + const additional = isSchemaObject(schema.additionalProperties) ? schema.additionalProperties : void 0; + let out; + const set = (key, next) => { + if (next === (out ?? value2)[key]) + return; + out ??= { ...value2 }; + out[key] = next; + }; + for (const [key, property] of Object.entries(properties)) { + if (key in value2) + set(key, this.coerceTypes(value2[key], property, base)); + } + for (const key of Object.keys(value2)) { + let matched = Object.hasOwn(properties, key); + for (const [pattern, item] of patterns) { + if (!pattern.test(key)) + continue; + matched = true; + set(key, this.coerceTypes((out ?? value2)[key], item, base)); + } + if (!matched && additional !== void 0) + set(key, this.coerceTypes(value2[key], additional, base)); + } + return out ?? value2; + } + coerceItems(value2, schema, base) { + return mapItems(value2, (item, i) => { + const itemSchema = itemSchemaAt(schema, i); + return isSchemaObject(itemSchema) ? this.coerceTypes(item, itemSchema, base) : item; + }); + } +}; +function itemSchemaAt(schema, index) { + if (Array.isArray(schema.items)) + return schema.items[index]; + if (Array.isArray(schema.prefixItems) && index < schema.prefixItems.length) + return schema.prefixItems[index]; + return schema.items; +} +function mapItems(items, map) { + let out; + for (let i = 0; i < items.length; i++) { + const next = map(items[i], i); + if (next === items[i]) + continue; + out ??= items.slice(); + out[i] = next; + } + return out ?? items; +} +function orValue(result, value2) { + return result === void 0 ? value2 : result.value; +} +function convertLiteral(literal, value2) { + if (literal === value2) + return value2; + const result = typeof literal === "string" ? tryString(value2) : typeof literal === "number" ? tryNumber(value2) : typeof literal === "boolean" ? tryBoolean(value2) : void 0; + return result !== void 0 && result.value === literal ? result.value : value2; +} +function tryString(value2) { + if (typeof value2 === "bigint" || typeof value2 === "boolean" || isFiniteNumber(value2)) + return { value: String(value2) }; + if (value2 === null) + return { value: "null" }; + if (typeof value2 === "string") + return { value: value2 }; + if (value2 === void 0) + return { value: "" }; + return void 0; +} +var MAX_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); +var MIN_BIGINT = BigInt(Number.MIN_SAFE_INTEGER); +function tryNumber(value2) { + if (typeof value2 === "bigint") + return value2 <= MAX_BIGINT && value2 >= MIN_BIGINT ? { value: Number(value2) } : void 0; + if (typeof value2 === "boolean") + return { value: value2 ? 1 : 0 }; + if (isFiniteNumber(value2)) + return { value: value2 }; + if (value2 === null || value2 === void 0) + return { value: 0 }; + if (typeof value2 !== "string") + return void 0; + const coerced = +value2; + if (Number.isFinite(coerced)) + return { value: coerced }; + const lowercase = value2.toLowerCase(); + if (lowercase === "false") + return { value: 0 }; + if (lowercase === "true") + return { value: 1 }; + const big = bigIntOfString(value2); + return big !== void 0 && big <= MAX_BIGINT && big >= MIN_BIGINT ? { value: Number(big) } : void 0; +} +function bigIntOfString(value2) { + if (/^-?(0|[1-9]\d*)n$/.test(value2)) + return BigInt(value2.slice(0, -1)); + if (/^-?(0|[1-9]\d*)\.\d+$/.test(value2)) + return BigInt(value2.split(".")[0]); + if (/^-?(0|[1-9]\d*)$/.test(value2)) + return BigInt(value2); + return void 0; +} +function tryBoolean(value2) { + if (typeof value2 === "bigint") + return value2 === 0n ? { value: false } : value2 === 1n ? { value: true } : void 0; + if (typeof value2 === "boolean") + return { value: value2 }; + if (isFiniteNumber(value2)) + return value2 === 0 ? { value: false } : value2 === 1 ? { value: true } : void 0; + if (value2 === null || value2 === void 0) + return { value: false }; + if (typeof value2 !== "string") + return void 0; + const lowercase = value2.toLowerCase(); + if (lowercase === "false" || value2 === "0") + return { value: false }; + if (lowercase === "true" || value2 === "1") + return { value: true }; + return void 0; +} +function tryNull(value2) { + if (typeof value2 === "bigint") + return value2 === 0n ? { value: null } : void 0; + if (typeof value2 === "boolean") + return value2 === false ? { value: null } : void 0; + if (isFiniteNumber(value2)) + return value2 === 0 ? { value: null } : void 0; + if (value2 === null || value2 === void 0) + return { value: null }; + if (typeof value2 !== "string") + return void 0; + const lowercase = value2.toLowerCase(); + return lowercase === "undefined" || lowercase === "null" || value2 === "" || value2 === "0" ? { value: null } : void 0; +} +function isFiniteNumber(value2) { + return typeof value2 === "number" && Number.isFinite(value2); +} +function typesOf(schema) { + if (typeof schema.type === "string") + return [schema.type]; + if (Array.isArray(schema.type)) + return schema.type.filter((type) => typeof type === "string"); + return []; +} +function matchesType(value2, type) { + switch (type) { + case "number": + return typeof value2 === "number"; + case "integer": + return typeof value2 === "number" && Number.isInteger(value2); + case "boolean": + return typeof value2 === "boolean"; + case "string": + return typeof value2 === "string"; + case "null": + return value2 === null; + case "array": + return Array.isArray(value2); + case "object": + return isPlainObject(value2); + default: + return false; + } +} +function coercePrimitive(value2, type) { + switch (type) { + case "number": + case "integer": { + if (value2 === null) + return 0; + if (typeof value2 === "string" && value2.trim() !== "") { + const parsed = Number(value2); + if (type === "number" ? Number.isFinite(parsed) : Number.isInteger(parsed)) + return parsed; + } + if (typeof value2 === "boolean") + return value2 ? 1 : 0; + return value2; + } + case "boolean": + if (value2 === null) + return false; + if (value2 === "true" || value2 === 1) + return true; + if (value2 === "false" || value2 === 0) + return false; + return value2; + case "string": + if (value2 === null) + return ""; + if (typeof value2 === "number" || typeof value2 === "boolean") + return String(value2); + return value2; + case "null": + return value2 === "" || value2 === 0 || value2 === false ? null : value2; + default: + return value2; + } +} +function isPlainObject(value2) { + return typeof value2 === "object" && value2 !== null && !Array.isArray(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/checks.js +var ALWAYS = () => true; +var NEVER = () => false; +var IS_STRING = (v) => typeof v === "string"; +var IS_NUMBER = (v) => typeof v === "number"; +var IS_INTEGER = (v) => Number.isInteger(v); +var IS_BOOLEAN = (v) => typeof v === "boolean"; +var IS_NULL = (v) => v === null; +var IS_ARRAY = (v) => Array.isArray(v); +var IS_BYTES = (v) => v instanceof Uint8Array; +var IS_OBJECT = (v) => isObject(v); +function isObject(v) { + return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Uint8Array); +} +var TYPE_CHECKS = /* @__PURE__ */ new Map([ + ["string", IS_STRING], + ["number", IS_NUMBER], + ["integer", IS_INTEGER], + ["boolean", IS_BOOLEAN], + ["null", IS_NULL], + ["array", IS_ARRAY], + ["object", IS_OBJECT], + ["bytes", IS_BYTES] +]); +var LEAF_CODES = /* @__PURE__ */ new Map([ + [IS_STRING, 1], + [IS_NUMBER, 2], + [IS_INTEGER, 3], + [IS_BOOLEAN, 4], + [IS_NULL, 5], + [ALWAYS, 6] +]); +function leafOk(code, x) { + switch (code) { + case 1: + return typeof x === "string"; + case 2: + return typeof x === "number"; + case 3: + return Number.isInteger(x); + case 4: + return typeof x === "boolean"; + case 5: + return x === null; + default: + return true; + } +} +var GUARD_NONE = 0; +var GUARD_STRICT = 1; +var GUARD_LOOSE = 2; +var CODE = 7; +var REQUIRED = 8; +var INHERITED = 16; +function and(parts) { + switch (parts.length) { + case 0: + return ALWAYS; + case 1: + return parts[0]; + case 2: { + const [a, b] = parts; + return (v) => a(v) && b(v); + } + case 3: { + const [a, b, c] = parts; + return (v) => a(v) && b(v) && c(v); + } + default: + return (v) => { + for (let i = 0; i < parts.length; i++) + if (!parts[i](v)) + return false; + return true; + }; + } +} +function indirect(slot) { + return (v) => slot.check(v); +} +function scopedCheck(scope, uri, check) { + return (v) => { + scope.push(uri); + const ok = check(v); + scope.pop(); + return ok; + }; +} +function typeCheck(types) { + const checks = types.map((t) => (typeof t === "string" ? TYPE_CHECKS.get(t) : void 0) ?? NEVER); + if (checks.length === 1) + return checks[0]; + return (v) => { + for (let i = 0; i < checks.length; i++) + if (checks[i](v)) + return true; + return false; + }; +} +function bytesCheck(min, max) { + return (v) => v instanceof Uint8Array && v.length >= min && v.length <= max; +} +function codePoints(s) { + let n = 0; + for (let i = 0; i < s.length; i++, n++) { + const c = s.charCodeAt(i); + if (c >= 55296 && c <= 56319 && i + 1 < s.length) { + const next = s.charCodeAt(i + 1); + if (next >= 56320 && next <= 57343) + i++; + } + } + return n; +} +function lengthOk(s, min, max) { + const units = s.length; + if (units < min) + return false; + if (units < 2 * min && codePoints(s) < min) + return false; + return units <= max || codePoints(s) <= max; +} +function stringCheck(strict, min, max, re, format) { + if (re !== void 0 && min === 0 && max === Number.POSITIVE_INFINITY && format === void 0) { + return strict ? (v) => typeof v === "string" && re.test(v) : (v) => typeof v !== "string" || re.test(v); + } + if (re === void 0 && format === void 0) { + return strict ? (v) => typeof v === "string" && lengthOk(v, min, max) : (v) => typeof v !== "string" || lengthOk(v, min, max); + } + return (v) => { + if (typeof v !== "string") + return !strict; + return lengthOk(v, min, max) && (re === void 0 || re.test(v)) && (format === void 0 || format(v)); + }; +} +function numberCheck(mode, min, max, xmin, xmax, multipleOf) { + return (v) => { + if (typeof v !== "number") + return mode === 0; + if (mode === 2 && !Number.isInteger(v)) + return false; + return v >= min && v <= max && v > xmin && v < xmax && (multipleOf === void 0 || isMultiple(v, multipleOf)); + }; +} +function isMultiple(v, d) { + const q = v / d; + if (Number.isInteger(q)) + return true; + if (!Number.isFinite(q)) + return v % d === 0; + const [vc, ve] = decimal(v); + const [dc, de] = decimal(d); + return ve >= de ? vc * 10n ** BigInt(ve - de) % dc === 0n : vc % (dc * 10n ** BigInt(de - ve)) === 0n; +} +function decimal(x) { + const [mantissa, exponent = "0"] = Math.abs(x).toString().split("e"); + const point = mantissa.indexOf("."); + const fraction = point < 0 ? 0 : mantissa.length - point - 1; + return [BigInt(mantissa.replace(".", "")), Number(exponent) - fraction]; +} +function guardObject(inner, strict) { + return strict ? (v) => isObject(v) && inner(v) : (v) => !isObject(v) || inner(v); +} +function guardArray(inner, strict) { + return strict ? (v) => Array.isArray(v) && inner(v) : (v) => !Array.isArray(v) || inner(v); +} +function declaredObject(entries2, requiredCount, closed, guard) { + const n = entries2.length; + return (v) => { + if (guard !== GUARD_NONE && !isObject(v)) + return guard === GUARD_LOOSE; + const o = v; + let required = 0; + for (const k in o) { + let i = 0; + while (i < n && entries2[i] !== k) + i += 3; + if (i === n) { + if (closed) + return false; + continue; + } + const x = o[k]; + if (x === void 0) + continue; + const f = entries2[i + 2]; + const c = f & CODE; + if (c !== 0 ? !leafOk(c, x) : !entries2[i + 1](x)) + return false; + if (f & REQUIRED) + required++; + } + return required === requiredCount; + }; +} +function openObject(entries2, keys, extraRequired, patterns, additional, guard) { + const known = patterns.length > 0 || additional !== void 0 ? new Set(keys) : void 0; + return (v) => { + if (guard !== GUARD_NONE && !isObject(v)) + return guard === GUARD_LOOSE; + const o = v; + for (let i = 0; i < entries2.length; i += 3) { + const k = entries2[i]; + const x = o[k]; + const f = entries2[i + 2]; + if (x === void 0 || f & INHERITED && !Object.hasOwn(o, k)) { + if (f & REQUIRED) + return false; + continue; + } + const c = f & CODE; + if (c !== 0 ? !leafOk(c, x) : !entries2[i + 1](x)) + return false; + } + for (let i = 0; i < extraRequired.length; i++) + if (!Object.hasOwn(o, extraRequired[i])) + return false; + if (known === void 0) + return true; + for (const k of Object.keys(o)) { + let matched = known.has(k); + for (let i = 0; i < patterns.length; i++) { + if (patterns[i][0].test(k)) { + matched = true; + if (!patterns[i][1](o[k])) + return false; + } + } + if (!matched && additional !== void 0 && !additional(o[k])) + return false; + } + return true; + }; +} +function propertyNamesCheck(check) { + return (v) => { + for (const k in v) + if (!check(k)) + return false; + return true; + }; +} +function propertyCountCheck(min, max) { + return (v) => { + let n = 0; + for (const _ in v) + n++; + return n >= min && n <= max; + }; +} +function dependentRequiredCheck(entries2) { + return (v) => { + const o = v; + for (const [k, list] of entries2) + if (Object.hasOwn(o, k)) { + for (const r of list) + if (!Object.hasOwn(o, r)) + return false; + } + return true; + }; +} +function dependentSchemasCheck(entries2) { + return (v) => { + for (const [k, check] of entries2) + if (Object.hasOwn(v, k) && !check(v)) + return false; + return true; + }; +} +function listCheck(guard, min, max, prefix, rest) { + const n = prefix.length; + const code = rest === void 0 ? 0 : LEAF_CODES.get(rest) ?? 0; + return (v) => { + if (!Array.isArray(v)) + return guard === GUARD_LOOSE; + const length = v.length; + if (length < min || length > max) + return false; + const m = length < n ? length : n; + for (let i = 0; i < m; i++) + if (!prefix[i](v[i])) + return false; + if (rest === void 0) + return true; + if (code !== 0) { + for (let i = n; i < length; i++) + if (!leafOk(code, v[i])) + return false; + return true; + } + for (let i = n; i < length; i++) + if (!rest(v[i])) + return false; + return true; + }; +} +function itemsCheck(prefix, rest) { + return listCheck(GUARD_NONE, 0, Number.POSITIVE_INFINITY, prefix, rest); +} +function lengthCheck(min, max) { + return (v) => v.length >= min && v.length <= max; +} +function containsCheck(check, min, max) { + return (v) => { + const list = v; + let n = 0; + for (let i = 0; i < list.length; i++) + if (check(list[i]) && ++n > max) + return false; + return n >= min; + }; +} +function uniqueCheck(v) { + const list = v; + for (let i = 1; i < list.length; i++) + for (let j = 0; j < i; j++) + if (deepEqual(list[i], list[j])) + return false; + return true; +} +function enumCheck(values) { + const primitives = values.filter((x) => typeof x !== "object" || x === null); + if (primitives.length === values.length && primitives.length <= 8) { + return (v) => { + for (let i = 0; i < primitives.length; i++) + if (primitives[i] === v) + return true; + return false; + }; + } + const set = new Set(primitives); + const objects = values.filter((x) => typeof x === "object" && x !== null); + return (v) => set.has(v) || typeof v === "object" && objects.some((o) => deepEqual(v, o)); +} +function constCheck(value2) { + if (typeof value2 !== "object" || value2 === null) + return (v) => v === value2; + return (v) => deepEqual(v, value2); +} +function anyCheck(checks) { + return (v) => { + for (let i = 0; i < checks.length; i++) + if (checks[i](v)) + return true; + return false; + }; +} +function oneCheck(checks) { + return (v) => { + let n = 0; + for (let i = 0; i < checks.length; i++) + if (checks[i](v) && ++n > 1) + return false; + return n === 1; + }; +} +function notCheck(check) { + return (v) => !check(v); +} +function ifCheck(cond, then, otherwise) { + return (v) => cond(v) ? then(v) : otherwise(v); +} +function taggedCheck(key, tags, checks) { + return (v) => { + if (!isObject(v)) + return false; + const tag = v[key]; + for (let i = 0; i < tags.length; i++) + if (tags[i] === tag) + return checks[i](v); + return false; + }; +} +function deepEqual(a, b) { + if (a === b) + return true; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) + return false; + const array = Array.isArray(a); + if (array !== Array.isArray(b)) + return false; + if (array) { + const x2 = a; + const y2 = b; + if (x2.length !== y2.length) + return false; + for (let i = 0; i < x2.length; i++) + if (!deepEqual(x2[i], y2[i])) + return false; + return true; + } + const x = a; + const y = b; + const keys = Object.keys(x); + if (keys.length !== Object.keys(y).length) + return false; + for (const k of keys) + if (!Object.hasOwn(y, k) || !deepEqual(x[k], y[k])) + return false; + return true; +} + +// node_modules/@earendil-works/pi-schema/dist/annotations.js +function newAnnotations() { + return { props: null, allProps: false, items: 0, allItems: false, indexes: null }; +} +function merge(a, b) { + if (b.props !== null) + for (const k of b.props) + addProp(a, k); + a.allProps ||= b.allProps; + if (b.items > a.items) + a.items = b.items; + a.allItems ||= b.allItems; + if (b.indexes !== null) + for (const i of b.indexes) + addIndex(a, i); +} +function addProp(a, key) { + if (a.props === null) + a.props = /* @__PURE__ */ new Set(); + a.props.add(key); +} +function addIndex(a, index) { + if (a.indexes === null) + a.indexes = /* @__PURE__ */ new Set(); + a.indexes.add(index); +} +var ANNOTATING_TRUE = () => true; +var ANNOTATING_FALSE = () => false; +function indirectAnnotating(slot) { + return (v, a) => slot.check(v, a); +} +function ignoreAnnotations(check) { + return (v) => check(v); +} +function withFreshAnnotations(check) { + return (v) => check(v, newAnnotations()); +} +function scopedAnnotating(scope, uri, check) { + return (v, a) => { + scope.push(uri); + const ok = check(v, a); + scope.pop(); + return ok; + }; +} +function isolateAnnotations(check) { + return (v, a) => { + const b = newAnnotations(); + if (!check(v, b)) + return false; + merge(a, b); + return true; + }; +} +function annotateAll(plain, parts) { + return (v, a) => { + if (!plain(v)) + return false; + for (let i = 0; i < parts.length; i++) + if (!parts[i](v, a)) + return false; + return true; + }; +} +function annotateObject(props, patterns, additional) { + const declared = new Set(props.map(([k]) => k)); + return (v, a) => { + if (!isObject(v)) + return true; + const seen = []; + for (const [k, check] of props) { + if (!Object.hasOwn(v, k)) + continue; + if (!check(v[k])) + return false; + seen.push(k); + } + for (const k of Object.keys(v)) { + let matched = declared.has(k); + for (const [re, check] of patterns) { + if (re.test(k)) { + if (!check(v[k])) + return false; + matched = true; + seen.push(k); + } + } + if (!matched && additional !== void 0) { + if (!additional(v[k])) + return false; + seen.push(k); + } + } + for (const k of seen) + addProp(a, k); + return true; + }; +} +function annotateArray(prefix, rest, contains, minContains, maxContains) { + return (v, a) => { + if (!Array.isArray(v)) + return true; + const m = Math.min(v.length, prefix.length); + for (let i = 0; i < m; i++) + if (!prefix[i](v[i])) + return false; + if (rest !== void 0) { + for (let i = prefix.length; i < v.length; i++) + if (!rest(v[i])) + return false; + a.allItems = true; + } + if (m > a.items) + a.items = m; + if (contains !== void 0) { + let n = 0; + for (let i = 0; i < v.length; i++) { + if (contains(v[i])) { + n++; + addIndex(a, i); + } + } + if (n < minContains || n > maxContains) + return false; + } + return true; + }; +} +function annotateAny(branches, exactlyOne) { + return (v, a) => { + let n = 0; + for (const branch of branches) { + const b = newAnnotations(); + if (branch(v, b)) { + n++; + merge(a, b); + } + } + return exactlyOne ? n === 1 : n > 0; + }; +} +function annotateIf(cond, then, otherwise) { + return (v, a) => { + const b = newAnnotations(); + if (cond(v, b)) { + merge(a, b); + return then === void 0 || then(v, a); + } + return otherwise === void 0 || otherwise(v, a); + }; +} +function annotateDependent(key, check) { + return (v, a) => !isObject(v) || !Object.hasOwn(v, key) || check(v, a); +} +function unevaluatedProperties(check) { + return (v, a) => { + if (!isObject(v) || a.allProps) + return true; + for (const k of Object.keys(v)) + if (!(a.props?.has(k) ?? false) && !check(v[k])) + return false; + a.allProps = true; + return true; + }; +} +function unevaluatedItems(check) { + return (v, a) => { + if (!Array.isArray(v) || a.allItems) + return true; + for (let i = a.items; i < v.length; i++) + if (!(a.indexes?.has(i) ?? false) && !check(v[i])) + return false; + a.allItems = true; + return true; + }; +} + +// node_modules/@earendil-works/pi-schema/dist/compiler.js +var RESTART = /* @__PURE__ */ Symbol("restart"); +var Compiler = class { + r; + draft; + formats; + /** Whether checks track the dynamic scope (the schema has a bookended `$dynamicRef`). */ + dynamic; + schema; + scope; + cache = /* @__PURE__ */ new Map(); + annotatingCache = /* @__PURE__ */ new Map(); + constructor(schema, options, dynamic) { + this.schema = schema; + this.r = new Resolver(schema, options); + this.draft = this.r.draft; + this.formats = options.formats; + this.dynamic = dynamic; + this.scope = []; + } + /** The check of the root schema. */ + root() { + return this.check(this.schema, this.r.rootBase); + } + /** The check of a schema (cached by schema object; a recursive reference goes through an indirection). */ + check(node, base) { + if (node === true) + return ALWAYS; + if (node === false) + return NEVER; + if (!isSchemaObject(node)) + throw new Error(`A schema must be an object or a boolean, got ${JSON.stringify(node)}`); + const cached = this.cache.get(node); + if (cached !== void 0) + return cached; + const bare = this.bareType(node); + if (bare !== void 0) + return bare; + const slot = { check: void 0 }; + this.cache.set(node, indirect(slot)); + const check = this.build(node, this.r.baseFor(node, base)); + slot.check = check; + this.cache.set(node, check); + return check; + } + build(node, base) { + if (this.draft === "7" && typeof node.$ref === "string") + return this.ref(node.$ref, base); + if (this.draft !== "7" && (node.unevaluatedProperties !== void 0 || node.unevaluatedItems !== void 0)) { + return withFreshAnnotations(this.annotating(node, base)); + } + const check = this.plain(node, base); + return this.dynamic && this.r.isResource(node, base) ? scopedCheck(this.scope, base, check) : check; + } + /** + * The module-level check of a schema that is only a `type` (with annotations), `ALWAYS` for one with no assertion + * at all; undefined for every other schema. These are the most common subschemas and cost nothing to share. + */ + bareType(node) { + let type; + for (const k in node) { + if (k === "type") + type = node.type; + else if (!NEUTRAL_KEYWORDS.has(k)) + return void 0; + } + if (type === void 0) + return ALWAYS; + return typeof type === "string" ? TYPE_CHECKS.get(type) : void 0; + } + /** Every keyword of `node`, not tracking annotations. */ + plain(node, base) { + const parts = []; + const types = node.type === void 0 ? void 0 : Array.isArray(node.type) ? node.type : [node.type]; + const single = types?.length === 1 ? types[0] : void 0; + let typeInGroup = false; + if (single === "bytes") { + parts.push(bytesCheck(number(node.minLength) ?? 0, number(node.maxLength) ?? Number.POSITIVE_INFINITY)); + typeInGroup = true; + } else { + const string = this.stringGroup(node, single === "string"); + if (string !== void 0) + parts.push(string); + const numeric = single === "number" || single === "integer"; + const number2 = this.numberGroup(node, single === "number" ? 1 : single === "integer" ? 2 : 0); + if (number2 !== void 0) + parts.push(number2); + const object = this.objectGroup(node, base, single === "object"); + if (object !== void 0) + parts.push(object); + const array = this.arrayGroup(node, base, single === "array"); + if (array !== void 0) + parts.push(array); + typeInGroup = single === "string" && string !== void 0 || numeric && number2 !== void 0 || single === "object" && object !== void 0 || single === "array" && array !== void 0; + } + if (types !== void 0 && !typeInGroup) + parts.unshift(typeCheck(types)); + this.inPlace(node, base, parts); + return and(parts); + } + // The keywords that apply to the value itself, whatever its type. + inPlace(node, base, parts) { + if (Array.isArray(node.enum)) + parts.push(enumCheck(node.enum)); + if (Object.hasOwn(node, "const")) + parts.push(constCheck(node.const)); + if (Array.isArray(node.allOf)) + parts.push(and(node.allOf.map((s) => this.check(s, base)))); + if (Array.isArray(node.anyOf)) + parts.push(this.union(node.anyOf, base, false)); + if (Array.isArray(node.oneOf)) + parts.push(this.union(node.oneOf, base, true)); + if (node.not !== void 0) + parts.push(notCheck(this.check(node.not, base))); + if (node.if !== void 0 && (node.then !== void 0 || node.else !== void 0)) { + const then = node.then === void 0 ? ALWAYS : this.check(node.then, base); + const otherwise = node.else === void 0 ? ALWAYS : this.check(node.else, base); + parts.push(ifCheck(this.check(node.if, base), then, otherwise)); + } + if (typeof node.$ref === "string") + parts.push(this.ref(node.$ref, base)); + if (this.draft !== "7" && typeof node.$dynamicRef === "string") { + parts.push(this.dynamicRef(node.$dynamicRef, base)); + } + } + union(branches, base, exactlyOne) { + const checks = () => branches.map((s) => this.check(s, base)); + return constUnion(branches, exactlyOne) ?? this.tagged(branches, base) ?? (exactlyOne ? oneCheck(checks()) : anyCheck(checks())); + } + ref(ref, base) { + const target = this.r.resolve(ref, base); + const check = this.check(target.node, target.base); + return this.dynamic && target.base !== base ? scopedCheck(this.scope, target.base, check) : check; + } + dynamicRef(ref, base) { + const resolved = this.bookended(ref, base); + if (resolved === void 0) + return this.ref(ref, base); + const { initial, name } = resolved; + const fallback = this.check(initial.node, initial.base); + const lookup = this.dynamicLookup(name, (node, b) => this.check(node, b)); + const scope = this.scope; + return (v) => { + for (let i = 0; i < scope.length; i++) { + const check = lookup(scope[i]); + if (check !== null) + return check(v); + } + return fallback(v); + }; + } + /** The initial target and anchor name of a `$dynamicRef` that resolves dynamically, or undefined for a plain ref. */ + bookended(ref, base) { + const initial = this.r.resolve(ref, base); + const uri = resolveUri(ref, base); + const name = uri.slice(documentOf(uri).length + 1); + const anchored = isSchemaObject(initial.node) && initial.node.$dynamicAnchor === name; + if (name === "" || name.startsWith("/") || !anchored) + return void 0; + if (!this.dynamic) + throw RESTART; + return { initial, name }; + } + // Resolved at run time against the dynamic scope, so a schema with a bookended $dynamicRef keeps its compiler. + dynamicLookup(name, compile2) { + const found = /* @__PURE__ */ new Map(); + return (uri) => { + const key = `${uri}#${name}`; + let check = found.get(key); + if (check === void 0) { + const target = this.r.dynamicAnchors.get(key); + check = target === void 0 ? null : compile2(target, this.r.baseOf.get(target) ?? uri); + found.set(key, check); + } + return check; + }; + } + /** anyOf/oneOf whose branches are all objects with a distinct required const on one key: the tag picks the branch. */ + tagged(branches, base) { + if (branches.length < 2) + return void 0; + const resolved = []; + for (const branch of branches) { + if (!isSchemaObject(branch)) + return void 0; + const ref = typeof branch.$ref === "string" && Object.keys(branch).length === 1 ? branch.$ref : void 0; + const node = ref === void 0 ? branch : this.r.resolve(ref, this.r.baseFor(branch, base)).node; + if (!isSchemaObject(node)) + return void 0; + resolved.push(node); + } + const first = resolved[0].properties; + if (!isSchemaObject(first)) + return void 0; + for (const key of Object.keys(first)) { + const tags = resolved.map((s) => tagOf(s, key)); + if (tags.some((t) => t === void 0) || new Set(tags).size !== tags.length) + continue; + return taggedCheck(key, tags, branches.map((s) => this.check(s, base))); + } + return void 0; + } + stringGroup(node, strict) { + const min = number(node.minLength) ?? 0; + const max = number(node.maxLength) ?? Number.POSITIVE_INFINITY; + const re = typeof node.pattern === "string" ? sharedRegex(node.pattern) : void 0; + const format = this.formats !== void 0 && typeof node.format === "string" && Object.hasOwn(this.formats, node.format) ? this.formats[node.format] : void 0; + if (min === 0 && max === Number.POSITIVE_INFINITY && re === void 0 && format === void 0) + return void 0; + return stringCheck(strict, min, max, re, format); + } + numberGroup(node, mode) { + const min = number(node.minimum); + const max = number(node.maximum); + const xmin = number(node.exclusiveMinimum); + const xmax = number(node.exclusiveMaximum); + const multipleOf = number(node.multipleOf); + if ([min, max, xmin, xmax, multipleOf].every((x) => x === void 0)) + return void 0; + const inf = Number.POSITIVE_INFINITY; + return numberCheck(mode, min ?? -inf, max ?? inf, xmin ?? -inf, xmax ?? inf, multipleOf); + } + objectGroup(node, base, strict) { + const onlyCore = OBJECT_EXTRAS.every((k) => node[k] === void 0); + const core = this.objectCore(node, base, onlyCore ? strict ? GUARD_STRICT : GUARD_LOOSE : GUARD_NONE); + if (core !== void 0 && onlyCore) + return core; + const parts = core === void 0 ? [] : [core]; + if (node.propertyNames !== void 0) + parts.push(propertyNamesCheck(this.check(node.propertyNames, base))); + const minProperties = number(node.minProperties); + const maxProperties = number(node.maxProperties); + if (minProperties !== void 0 || maxProperties !== void 0) { + parts.push(propertyCountCheck(minProperties ?? 0, maxProperties ?? Number.POSITIVE_INFINITY)); + } + const dependentRequired = []; + const dependentSchemas = []; + if (this.draft !== "7") { + for (const [k, list] of entries(node.dependentRequired)) + if (Array.isArray(list)) + dependentRequired.push([k, list]); + for (const [k, s] of entries(node.dependentSchemas)) + dependentSchemas.push([k, this.check(s, base)]); + } + for (const [k, d] of entries(node.dependencies)) { + if (Array.isArray(d)) + dependentRequired.push([k, d]); + else + dependentSchemas.push([k, this.check(d, base)]); + } + if (dependentRequired.length > 0) + parts.push(dependentRequiredCheck(dependentRequired)); + if (dependentSchemas.length > 0) + parts.push(dependentSchemasCheck(dependentSchemas)); + if (parts.length === 0) + return strict ? IS_OBJECT : void 0; + return guardObject(and(parts), strict); + } + objectCore(node, base, guard) { + const properties = isSchemaObject(node.properties) ? node.properties : {}; + const required = Array.isArray(node.required) ? node.required.filter((k) => typeof k === "string") : []; + const patterns = isSchemaObject(node.patternProperties) ? node.patternProperties : {}; + const additional = node.additionalProperties; + const keys = Object.keys(properties); + const patternKeys = Object.keys(patterns); + if (keys.length + required.length + patternKeys.length === 0 && (additional === void 0 || additional === true)) { + return void 0; + } + const entries2 = []; + for (const k of keys) { + const check = this.check(properties[k], base); + const flags = (LEAF_CODES.get(check) ?? 0) | (required.includes(k) ? REQUIRED : 0) | (k in Object.prototype ? INHERITED : 0); + entries2.push(k, check, flags); + } + const extraRequired = required.filter((k) => !Object.hasOwn(properties, k)); + const pats = patternKeys.map((p) => [sharedRegex(p), this.check(patterns[p], base)]); + const extra = additional === void 0 || additional === true ? void 0 : this.check(additional, base); + if (pats.length === 0 && extraRequired.length === 0 && (extra === void 0 || additional === false)) { + const requiredCount = keys.filter((k) => required.includes(k)).length; + return declaredObject(entries2, requiredCount, additional === false, guard); + } + return openObject(entries2, keys, extraRequired, pats, extra, guard); + } + arrayGroup(node, base, strict) { + let prefix = []; + let rest; + if (Array.isArray(node.items)) { + prefix = node.items; + rest = node.additionalItems; + } else { + if (this.draft !== "7" && Array.isArray(node.prefixItems)) + prefix = node.prefixItems; + rest = node.items; + } + const prefixChecks = prefix.map((s) => this.check(s, base)); + const restCheck = rest === void 0 || rest === true ? void 0 : this.check(rest, base); + const minItems = number(node.minItems); + const maxItems = number(node.maxItems); + if (node.contains === void 0 && node.uniqueItems !== true && (prefixChecks.length > 0 || restCheck !== void 0)) { + const guard = strict ? GUARD_STRICT : GUARD_LOOSE; + return listCheck(guard, minItems ?? 0, maxItems ?? Number.POSITIVE_INFINITY, prefixChecks, restCheck); + } + const parts = []; + if (prefixChecks.length > 0 || restCheck !== void 0) + parts.push(itemsCheck(prefixChecks, restCheck)); + if (minItems !== void 0 || maxItems !== void 0) { + parts.push(lengthCheck(minItems ?? 0, maxItems ?? Number.POSITIVE_INFINITY)); + } + if (node.contains !== void 0) { + const min = this.draft === "7" ? 1 : number(node.minContains) ?? 1; + const max = this.draft === "7" ? Number.POSITIVE_INFINITY : number(node.maxContains) ?? Number.POSITIVE_INFINITY; + parts.push(containsCheck(this.check(node.contains, base), min, max)); + } + if (node.uniqueItems === true) + parts.push(uniqueCheck); + if (parts.length === 0) + return strict ? IS_ARRAY : void 0; + return guardArray(and(parts), strict); + } + /** The annotating check of a schema evaluated in place (only below `unevaluatedProperties`/`unevaluatedItems`). */ + annotating(node, base) { + if (node === true) + return ANNOTATING_TRUE; + if (node === false) + return ANNOTATING_FALSE; + if (!isSchemaObject(node)) + throw new Error(`A schema must be an object or a boolean, got ${JSON.stringify(node)}`); + const cached = this.annotatingCache.get(node); + if (cached !== void 0) + return cached; + const slot = { check: void 0 }; + this.annotatingCache.set(node, indirectAnnotating(slot)); + const check = this.buildAnnotating(node, this.r.baseFor(node, base)); + slot.check = check; + this.annotatingCache.set(node, check); + return check; + } + buildAnnotating(node, base) { + const rest = {}; + let annotates = false; + for (const k of Object.keys(node)) { + if (ANNOTATING.has(k)) + annotates = true; + else + rest[k] = node[k]; + } + const plain = this.plain(rest, base); + const parts = []; + if (annotates) + this.annotatingParts(node, base, parts); + const all = parts.length === 0 ? ignoreAnnotations(plain) : annotateAll(plain, parts); + const own = node.unevaluatedProperties !== void 0 || node.unevaluatedItems !== void 0 ? isolateAnnotations(all) : all; + return this.dynamic && this.r.isResource(node, base) ? scopedAnnotating(this.scope, base, own) : own; + } + annotatingParts(node, base, parts) { + if (node.properties !== void 0 || node.patternProperties !== void 0 || node.additionalProperties !== void 0) { + const props = entries(node.properties).map(([k, s]) => [k, this.check(s, base)]); + const pats = entries(node.patternProperties).map(([p, s]) => [sharedRegex(p), this.check(s, base)]); + const extra = node.additionalProperties === void 0 ? void 0 : this.check(node.additionalProperties, base); + parts.push(annotateObject(props, pats, extra)); + } + if (node.prefixItems !== void 0 || node.items !== void 0 || node.contains !== void 0) { + const prefix = Array.isArray(node.prefixItems) ? node.prefixItems : Array.isArray(node.items) ? node.items : []; + const rest = Array.isArray(node.items) ? node.additionalItems : node.items; + parts.push(annotateArray(prefix.map((s) => this.check(s, base)), rest === void 0 ? void 0 : this.check(rest, base), node.contains === void 0 ? void 0 : this.check(node.contains, base), number(node.minContains) ?? 1, number(node.maxContains) ?? Number.POSITIVE_INFINITY)); + } + if (Array.isArray(node.allOf)) + for (const s of node.allOf) + parts.push(this.annotating(s, base)); + if (Array.isArray(node.anyOf)) + parts.push(annotateAny(node.anyOf.map((s) => this.annotating(s, base)), false)); + if (Array.isArray(node.oneOf)) + parts.push(annotateAny(node.oneOf.map((s) => this.annotating(s, base)), true)); + if (node.if !== void 0) { + const then = node.then === void 0 ? void 0 : this.annotating(node.then, base); + const otherwise = node.else === void 0 ? void 0 : this.annotating(node.else, base); + parts.push(annotateIf(this.annotating(node.if, base), then, otherwise)); + } + if (typeof node.$ref === "string") + parts.push(this.annotatingRef(node.$ref, base)); + if (typeof node.$dynamicRef === "string") + parts.push(this.annotatingDynamicRef(node.$dynamicRef, base)); + for (const [k, s] of entries(node.dependentSchemas)) + parts.push(annotateDependent(k, this.annotating(s, base))); + if (node.unevaluatedProperties !== void 0) { + parts.push(unevaluatedProperties(this.check(node.unevaluatedProperties, base))); + } + if (node.unevaluatedItems !== void 0) + parts.push(unevaluatedItems(this.check(node.unevaluatedItems, base))); + } + annotatingRef(ref, base) { + const target = this.r.resolve(ref, base); + const check = this.annotating(target.node, target.base); + return this.dynamic && target.base !== base ? scopedAnnotating(this.scope, target.base, check) : check; + } + annotatingDynamicRef(ref, base) { + const resolved = this.bookended(ref, base); + if (resolved === void 0) + return this.annotatingRef(ref, base); + const { initial, name } = resolved; + const fallback = this.annotating(initial.node, initial.base); + const lookup = this.dynamicLookup(name, (node, b) => this.annotating(node, b)); + const scope = this.scope; + return (v, a) => { + for (let i = 0; i < scope.length; i++) { + const check = lookup(scope[i]); + if (check !== null) + return check(v, a); + } + return fallback(v, a); + }; + } +}; +var NEUTRAL_KEYWORDS = /* @__PURE__ */ new Set([ + "$schema", + "$comment", + "title", + "description", + "default", + "examples", + "deprecated", + "readOnly", + "writeOnly", + "$defs", + "definitions", + "contentEncoding", + "contentMediaType", + "minContains", + "maxContains", + "$vocabulary" +]); +var OBJECT_EXTRAS = [ + "propertyNames", + "minProperties", + "maxProperties", + "dependentRequired", + "dependentSchemas", + "dependencies" +]; +var ANNOTATING = /* @__PURE__ */ new Set([ + "properties", + "patternProperties", + "additionalProperties", + "prefixItems", + "items", + "additionalItems", + "contains", + "minContains", + "maxContains", + "allOf", + "anyOf", + "oneOf", + "if", + "then", + "else", + "$ref", + "$dynamicRef", + "dependentSchemas", + "unevaluatedProperties", + "unevaluatedItems" +]); +function constUnion(branches, exactlyOne) { + const values = []; + for (const branch of branches) { + if (!isSchemaObject(branch) || !Object.hasOwn(branch, "const")) + return void 0; + for (const k of Object.keys(branch)) + if (k !== "const" && !NEUTRAL_KEYWORDS.has(k)) + return void 0; + if (typeof branch.const === "object" && branch.const !== null) + return void 0; + if (exactlyOne && values.includes(branch.const)) + return void 0; + values.push(branch.const); + } + return enumCheck(values); +} +function tagOf(branch, key) { + const properties = branch.properties; + if (branch.type !== "object" || !Array.isArray(branch.required) || !branch.required.includes(key)) + return void 0; + const tag = isSchemaObject(properties) ? properties[key] : void 0; + if (!isSchemaObject(tag) || !Object.hasOwn(tag, "const")) + return void 0; + return typeof tag.const === "object" && tag.const !== null ? void 0 : tag.const; +} +function number(value2) { + return typeof value2 === "number" ? value2 : void 0; +} +function entries(value2) { + return isSchemaObject(value2) ? Object.entries(value2) : []; +} + +// node_modules/@earendil-works/pi-schema/dist/explain.js +var Explainer = class { + c; + schema; + /** + * The errors of `$ref` targets for object values, within one errors() call: a recursive union explains each branch + * to pick the one to blame, and every branch would explain the same children again, exponentially in the depth. + */ + memo = void 0; + constructor(compiler2, schema) { + this.c = compiler2; + this.schema = schema; + compiler2.root(); + } + /** The errors of `value`, at least one (`value` failed the check). */ + errors(value2) { + const out = []; + try { + this.walk(this.schema, this.c.r.rootBase, value2, "", out); + } finally { + this.memo = void 0; + } + if (out.length === 0) + out.push({ path: "", keyword: "schema", message: "invalid" }); + return out; + } + ok(node, base, v) { + return this.c.check(node, base)(v); + } + // For $dynamicRef schemas this check runs outside the original dynamic scope, so their errors are approximate. + walk(node, base, v, path, out) { + if (node === true) + return; + if (node === false) { + out.push({ path, keyword: "false", message: "no value is allowed here" }); + return; + } + if (!isSchemaObject(node)) + return; + const b = this.c.r.baseFor(node, base); + if (this.ok(node, b, v)) + return; + const start = out.length; + const push = (keyword, message, at = path) => { + out.push({ path: at, keyword, message }); + }; + if (this.c.draft === "7" && typeof node.$ref === "string") { + this.walkRef(node.$ref, b, v, path, out); + return; + } + if (node.type !== void 0) { + const types = Array.isArray(node.type) ? node.type : [node.type]; + const checks = types.map((t) => (typeof t === "string" ? TYPE_CHECKS.get(t) : void 0) ?? NEVER); + if (!checks.some((check) => check(v))) { + push("type", `expected ${types.join(" or ")}, got ${typeName(v)}`); + return; + } + } + if (Array.isArray(node.enum) && !enumCheck(node.enum)(v)) + push("enum", `expected one of ${listValues(node.enum)}`); + if (Object.hasOwn(node, "const") && !constCheck(node.const)(v)) + push("const", `expected ${JSON.stringify(node.const)}`); + if (typeof v === "string" && node.type !== "bytes") + this.walkString(node, v, push); + if (v instanceof Uint8Array && node.type === "bytes") + this.walkBytes(node, v, push); + if (typeof v === "number") + this.walkNumber(node, v, push); + if (isObject(v)) + this.walkObject(node, b, v, path, out, push); + if (Array.isArray(v)) + this.walkArray(node, b, v, path, out, push); + if (Array.isArray(node.allOf)) + for (const s of node.allOf) + this.walk(s, b, v, path, out); + if (Array.isArray(node.anyOf)) + this.walkUnion(node.anyOf, b, v, path, out, false); + if (Array.isArray(node.oneOf)) + this.walkUnion(node.oneOf, b, v, path, out, true); + if (node.not !== void 0 && this.ok(node.not, b, v)) + push("not", "must not match the schema in not"); + if (node.if !== void 0) { + const branch = this.ok(node.if, b, v) ? node.then : node.else; + if (branch !== void 0) + this.walk(branch, b, v, path, out); + } + if (typeof node.$ref === "string") + this.walkRef(node.$ref, b, v, path, out); + if (out.length === start && (node.unevaluatedProperties !== void 0 || node.unevaluatedItems !== void 0)) { + this.walkUnevaluated(node, b, v, path, out); + } + if (out.length === start && typeof node.$dynamicRef === "string") { + push("$dynamicRef", "does not match the dynamically referenced schema"); + } + } + walkRef(ref, base, v, path, out) { + const target = this.c.r.resolve(ref, base); + if (target.node === false && this.c.r.isUnresolved(ref, base)) { + out.push({ path, keyword: "$ref", message: `unresolved reference ${ref}` }); + return; + } + if (!isSchemaObject(target.node) || typeof v !== "object" || v === null) { + this.walk(target.node, target.base, v, path, out); + return; + } + this.memo ??= /* @__PURE__ */ new Map(); + let values = this.memo.get(target.node); + const known = values?.get(v); + if (known !== void 0 && known.path === path) { + out.push(...known.errors); + return; + } + const errors = []; + this.walk(target.node, target.base, v, path, errors); + if (values === void 0) { + values = /* @__PURE__ */ new Map(); + this.memo.set(target.node, values); + } + values.set(v, { path, errors }); + out.push(...errors); + } + walkString(node, v, push) { + const n = codePoints(v); + if (typeof node.minLength === "number" && n < node.minLength) { + push("minLength", `expected at least ${node.minLength} characters`); + } + if (typeof node.maxLength === "number" && n > node.maxLength) { + push("maxLength", `expected at most ${node.maxLength} characters`); + } + if (typeof node.pattern === "string" && !sharedRegex(node.pattern).test(v)) { + push("pattern", `expected to match ${node.pattern}`); + } + const formats2 = this.c.formats; + const format = formats2 !== void 0 && typeof node.format === "string" && Object.hasOwn(formats2, node.format) ? formats2[node.format] : void 0; + if (format !== void 0 && !format(v)) + push("format", `expected format ${node.format}`); + } + walkBytes(node, v, push) { + if (typeof node.minLength === "number" && v.length < node.minLength) { + push("minLength", `expected at least ${node.minLength} bytes`); + } + if (typeof node.maxLength === "number" && v.length > node.maxLength) { + push("maxLength", `expected at most ${node.maxLength} bytes`); + } + } + walkNumber(node, v, push) { + if (typeof node.minimum === "number" && v < node.minimum) + push("minimum", `expected >= ${node.minimum}`); + if (typeof node.maximum === "number" && v > node.maximum) + push("maximum", `expected <= ${node.maximum}`); + if (typeof node.exclusiveMinimum === "number" && v <= node.exclusiveMinimum) { + push("exclusiveMinimum", `expected > ${node.exclusiveMinimum}`); + } + if (typeof node.exclusiveMaximum === "number" && v >= node.exclusiveMaximum) { + push("exclusiveMaximum", `expected < ${node.exclusiveMaximum}`); + } + if (typeof node.multipleOf === "number" && !isMultiple(v, node.multipleOf)) { + push("multipleOf", `expected a multiple of ${node.multipleOf}`); + } + } + walkObject(node, b, v, path, out, push) { + const properties = isSchemaObject(node.properties) ? node.properties : {}; + const required = Array.isArray(node.required) ? node.required : []; + for (const k of required) { + if (typeof k === "string" && (!Object.hasOwn(v, k) || v[k] === void 0)) + push("required", "missing", pointer(path, k)); + } + for (const k of Object.keys(properties)) { + if (Object.hasOwn(v, k) && v[k] !== void 0) + this.walk(properties[k], b, v[k], pointer(path, k), out); + } + const patterns = isSchemaObject(node.patternProperties) ? Object.entries(node.patternProperties) : []; + for (const k of Object.keys(v)) { + let matched = Object.hasOwn(properties, k); + for (const [p, s] of patterns) { + if (sharedRegex(p).test(k)) { + matched = true; + this.walk(s, b, v[k], pointer(path, k), out); + } + } + if (!matched && node.additionalProperties !== void 0) { + if (node.additionalProperties === false) + push("additionalProperties", "unknown field", pointer(path, k)); + else + this.walk(node.additionalProperties, b, v[k], pointer(path, k), out); + } + if (node.propertyNames !== void 0 && !this.ok(node.propertyNames, b, k)) { + push("propertyNames", "invalid property name", pointer(path, k)); + } + } + const count = Object.keys(v).length; + if (typeof node.minProperties === "number" && count < node.minProperties) { + push("minProperties", `expected at least ${node.minProperties} properties`); + } + if (typeof node.maxProperties === "number" && count > node.maxProperties) { + push("maxProperties", `expected at most ${node.maxProperties} properties`); + } + const dependencies = { + ...this.c.draft === "7" || !isSchemaObject(node.dependentRequired) ? {} : node.dependentRequired, + ...isSchemaObject(node.dependencies) ? node.dependencies : {} + }; + for (const [k, d] of Object.entries(dependencies)) { + if (!Object.hasOwn(v, k)) + continue; + if (!Array.isArray(d)) + this.walk(d, b, v, path, out); + else + for (const r of d) + if (!Object.hasOwn(v, r)) + push("dependentRequired", `required when ${k} is present`, pointer(path, r)); + } + if (this.c.draft !== "7" && isSchemaObject(node.dependentSchemas)) { + for (const [k, s] of Object.entries(node.dependentSchemas)) + if (Object.hasOwn(v, k)) + this.walk(s, b, v, path, out); + } + } + walkArray(node, b, v, path, out, push) { + let prefix = []; + let rest; + if (Array.isArray(node.items)) { + prefix = node.items; + rest = node.additionalItems; + } else { + if (this.c.draft !== "7" && Array.isArray(node.prefixItems)) + prefix = node.prefixItems; + rest = node.items; + } + for (let i = 0; i < v.length; i++) { + const s = i < prefix.length ? prefix[i] : rest; + if (s !== void 0) + this.walk(s, b, v[i], `${path}/${i}`, out); + } + if (typeof node.minItems === "number" && v.length < node.minItems) { + push("minItems", `expected at least ${node.minItems} items`); + } + if (typeof node.maxItems === "number" && v.length > node.maxItems) { + push("maxItems", `expected at most ${node.maxItems} items`); + } + if (node.uniqueItems === true && !uniqueCheck(v)) + push("uniqueItems", "expected unique items"); + if (node.contains !== void 0) { + const draft7 = this.c.draft === "7"; + const min = draft7 || typeof node.minContains !== "number" ? 1 : node.minContains; + const max = draft7 || typeof node.maxContains !== "number" ? Number.POSITIVE_INFINITY : node.maxContains; + if (!containsCheck(this.c.check(node.contains, b), min, max)(v)) { + push("contains", containsMessage(min, max)); + } + } + } + walkUnion(branches, b, v, path, out, exactlyOne) { + const matches = branches.filter((s) => this.ok(s, b, v)).length; + if (matches === 1 || !exactlyOne && matches > 0) + return; + const keyword = exactlyOne ? "oneOf" : "anyOf"; + if (matches > 1) { + out.push({ path, keyword, message: "matches more than one alternative" }); + return; + } + const tag = this.tagOf(branches, b); + if (tag !== void 0 && isObject(v)) { + const i = tag.values.indexOf(v[tag.key]); + if (i < 0) + out.push({ + path: pointer(path, tag.key), + keyword, + message: `expected one of ${listValues(tag.values)}` + }); + else + this.walk(branches[i], b, v, path, out); + return; + } + let best; + for (const s of branches) { + const errors = []; + this.walk(s, b, v, path, errors); + if (errors.length === 0) + continue; + const declared = isObject(v) ? this.declaredKeys(s, b, v) : 0; + const better = best === void 0 || declared > best.declared || declared === best.declared && errors[0].path.length > best.errors[0].path.length; + if (better) + best = { errors, declared }; + } + out.push(...best?.errors ?? [{ path, keyword, message: "matches no alternative" }]); + } + /** How many of the keys of `v` the branch `s` (through a `$ref`) declares in `properties`. */ + declaredKeys(s, b, v) { + const node = isSchemaObject(s) && typeof s.$ref === "string" ? this.c.r.resolve(s.$ref, b).node : s; + const properties = isSchemaObject(node) && isSchemaObject(node.properties) ? node.properties : {}; + return Object.keys(v).filter((k) => Object.hasOwn(properties, k)).length; + } + tagOf(branches, b) { + const resolved = branches.map((s) => isSchemaObject(s) && typeof s.$ref === "string" ? this.c.r.resolve(s.$ref, b).node : s); + const first = resolved[0]; + if (!isSchemaObject(first) || !isSchemaObject(first.properties)) + return void 0; + for (const key of Object.keys(first.properties)) { + const values = resolved.map((s) => { + const tag = isSchemaObject(s) && isSchemaObject(s.properties) ? s.properties[key] : void 0; + return isSchemaObject(tag) ? tag.const : void 0; + }); + const primitive = values.every((x) => x !== void 0 && (typeof x !== "object" || x === null)); + if (primitive && new Set(values).size === values.length) + return { key, values }; + } + return void 0; + } + walkUnevaluated(node, b, v, path, out) { + const { unevaluatedProperties: unevaluatedProperties2, unevaluatedItems: unevaluatedItems2, ...rest } = node; + const a = newAnnotations(); + this.c.annotating(rest, b)(v, a); + if (isObject(v) && unevaluatedProperties2 !== void 0 && !a.allProps) { + for (const k of Object.keys(v)) { + if (a.props?.has(k)) + continue; + if (unevaluatedProperties2 === false) { + out.push({ path: pointer(path, k), keyword: "unevaluatedProperties", message: "unknown field" }); + } else + this.walk(unevaluatedProperties2, b, v[k], pointer(path, k), out); + } + } + if (Array.isArray(v) && unevaluatedItems2 !== void 0 && !a.allItems) { + for (let i = a.items; i < v.length; i++) { + if (a.indexes?.has(i)) + continue; + if (unevaluatedItems2 === false) { + out.push({ path: `${path}/${i}`, keyword: "unevaluatedItems", message: "unexpected item" }); + } else + this.walk(unevaluatedItems2, b, v[i], `${path}/${i}`, out); + } + } + } +}; +var LISTED_VALUES = 20; +function listValues(values) { + if (values.length <= LISTED_VALUES) + return JSON.stringify(values); + const listed = JSON.stringify(values.slice(0, LISTED_VALUES)); + return `${listed.slice(0, -1)}, ... and ${values.length - LISTED_VALUES} more]`; +} +function containsMessage(min, max) { + if (max === Number.POSITIVE_INFINITY) + return `expected at least ${min} matching ${min === 1 ? "item" : "items"}`; + if (min === 0) + return `expected at most ${max} matching ${max === 1 ? "item" : "items"}`; + return `expected between ${min} and ${max} matching items`; +} +function pointer(path, key) { + return `${path}/${String(key).replace(/~/g, "~0").replace(/\//g, "~1")}`; +} +function typeName(v) { + if (v === null) + return "null"; + if (Array.isArray(v)) + return "array"; + if (v instanceof Uint8Array) + return "bytes"; + if (typeof v === "number" && Number.isInteger(v)) + return "integer"; + return typeof v; +} + +// node_modules/@earendil-works/pi-schema/dist/validator.js +function compile(schema, options = NO_OPTIONS) { + const check = compileCheck(schema, options); + return options.coerce === true ? new CoercingValidator(schema, options, check) : new Validator(schema, options, check); +} +var NO_OPTIONS = Object.freeze({}); +var Validator = class { + /** Whether `value` is valid. A plain function: it may be passed around without its validator. */ + check; + schema; + options; + explainer = void 0; + constructor(schema, options, check) { + this.check = check; + this.schema = schema; + this.options = options; + } + /** + * Why `value` is invalid: one error per failing keyword, `[]` when it is valid. Built on the first failure and + * kept, so a validator whose values always pass never pays for it. + */ + errors(value2) { + if (this.check(value2)) + return []; + this.explainer ??= new Explainer(compiler(this.schema, this.options), this.schema); + return this.explainer.errors(value2); + } +}; +var CoercingValidator = class extends Validator { + coercer = void 0; + /** + * `value` coerced towards the schema: numeric strings to numbers, `"true"` to `true`, a single value to a one-item + * array, an optional `null` dropped, the first union branch the value converts to (the README lists every rule). + * Only the changed paths are copied; `value` comes back itself when nothing changes, and is never modified. Values + * that cannot be coerced stay as they are. The rules are built on the first call. + */ + coerce(value2) { + this.coercer ??= new Coercer(compiler(this.schema, this.options), this.schema); + return this.coercer.coerce(value2); + } + /** Coerces `value`, then checks it: the coerced value when it is valid, else its errors. */ + parse(value2) { + const coerced = this.coerce(value2); + return this.check(coerced) ? { ok: true, value: coerced } : { ok: false, errors: this.errors(coerced) }; + } +}; +function compileCheck(schema, options) { + return compiler(schema, options).root(); +} +function compiler(schema, options) { + const plain = new Compiler(schema, options, false); + try { + plain.root(); + return plain; + } catch (error) { + if (error !== RESTART) + throw error; + return new Compiler(schema, options, true); + } +} + +// node_modules/@earendil-works/chord/dist/json.js +var DATA_DESCRIPTOR = { + value: void 0, + writable: true, + enumerable: true, + configurable: true +}; +function copyJson(value2, options) { + return copy(value2, void 0, options?.omitUndefinedProperties === true); +} +function copy(value2, ancestors, omitUndefinedProperties) { + if (value2 === null || typeof value2 === "string" || typeof value2 === "boolean") + return value2; + if (typeof value2 === "number") { + if (Number.isFinite(value2)) + return value2; + throw new TypeError("Value contains a non-finite number and is not strict JSON"); + } + if (typeof value2 !== "object") + throw new TypeError(`Value contains a non-JSON ${typeof value2}; expected strict JSON`); + const active = ancestors ?? /* @__PURE__ */ new Set(); + if (active.has(value2)) + throw new TypeError("Value contains cycles and is not strict JSON"); + active.add(value2); + try { + if (Array.isArray(value2)) { + if (Object.getPrototypeOf(value2) !== Array.prototype || Reflect.ownKeys(value2).length !== value2.length + 1) { + throw new TypeError("Value must contain strict JSON dense plain arrays"); + } + const result2 = new Array(value2.length); + for (let index = 0; index < value2.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value2, index); + if (descriptor === void 0 || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError("Value must contain strict JSON enumerable indexed data properties"); + } + defineData(result2, String(index), copy(descriptor.value, active, omitUndefinedProperties)); + } + return result2; + } + const prototype = Object.getPrototypeOf(value2); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Value must contain strict JSON plain objects or arrays"); + } + const result = Object.create(prototype); + for (const key of Reflect.ownKeys(value2)) { + if (typeof key === "symbol") + throw new TypeError("Value contains a symbol key and is not strict JSON"); + const descriptor = Object.getOwnPropertyDescriptor(value2, key); + if (!descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError("Value must contain strict JSON enumerable data properties"); + } + if (descriptor.value === void 0 && omitUndefinedProperties) + continue; + defineData(result, key, copy(descriptor.value, active, omitUndefinedProperties)); + } + return result; + } finally { + active.delete(value2); + } +} +function defineData(target, key, value2) { + DATA_DESCRIPTOR.value = value2; + Object.defineProperty(target, key, DATA_DESCRIPTOR); + DATA_DESCRIPTOR.value = void 0; +} + +// node_modules/@earendil-works/chord/dist/delta/apply-immutable-trusted.js +var DATA_DESCRIPTOR2 = { + value: void 0, + writable: true, + enumerable: true, + configurable: true +}; +function applyImmutableTrusted(target, operations) { + let root2 = target; + const owned = /* @__PURE__ */ new WeakSet(); + for (const operation of operations) { + if (operation[0] === "r") { + root2 = operation[1]; + continue; + } + if (operation[0] === "p" || operation[0] === "m") { + const copied2 = copyPath(root2, operation[1], operation[1].length, owned); + root2 = copied2.root; + if (!Array.isArray(copied2.target)) + throw new TypeError("Trusted array operation target is not an array"); + if (operation[0] === "p") + spliceTrusted(copied2.target, operation[2], operation[3], operation[4]); + else + permuteTrusted(copied2.target, operation[2]); + continue; + } + const path = operation[1]; + const copied = copyPath(root2, path, path.length - 1, owned); + root2 = copied.root; + const key = path[path.length - 1]; + switch (operation[0]) { + case "s": + defineData2(copied.target, key, operation[2]); + break; + case "d": + if (Array.isArray(copied.target)) { + if (typeof key !== "number") + throw new TypeError("Trusted array deletion key is not numeric"); + spliceTrusted(copied.target, key, 1, []); + } else + Reflect.deleteProperty(copied.target, key); + break; + case "a": { + const current = read(copied.target, key); + if (typeof current !== "string") + throw new TypeError("Trusted append target is not a string"); + defineData2(copied.target, key, `${current}${operation[2]}`); + break; + } + case "t": { + const current = read(copied.target, key); + if (typeof current !== "string") + throw new TypeError("Trusted truncate target is not a string"); + defineData2(copied.target, key, current.slice(operation[2])); + break; + } + } + } + return root2; +} +function copyPath(root2, path, length, owned) { + if (!isContainer(root2)) + throw new TypeError("Trusted operation root is not a container"); + let copiedRoot = root2; + if (!owned.has(root2)) { + copiedRoot = shallowCopy(root2); + owned.add(copiedRoot); + } + let destination = copiedRoot; + for (let index = 0; index < length; index++) { + const segment = path[index]; + const child = read(destination, segment); + if (!isContainer(child)) + throw new TypeError("Trusted operation path is not a container"); + if (owned.has(child)) { + destination = child; + continue; + } + const copy2 = shallowCopy(child); + defineData2(destination, segment, copy2); + owned.add(copy2); + destination = copy2; + } + return { root: copiedRoot, target: destination }; +} +function shallowCopy(value2) { + if (Array.isArray(value2)) + return Array.from(value2); + return Object.getPrototypeOf(value2) === null ? Object.assign(/* @__PURE__ */ Object.create(null), value2) : { ...value2 }; +} +function read(target, key) { + return target[key]; +} +function defineData2(target, key, value2) { + DATA_DESCRIPTOR2.value = value2; + Object.defineProperty(target, key, DATA_DESCRIPTOR2); + DATA_DESCRIPTOR2.value = void 0; +} +function spliceTrusted(target, start, remove, items) { + const oldLength = target.length; + const delta = items.length - remove; + if (delta > 0) { + target.length = oldLength + delta; + target.copyWithin(start + items.length, start + remove, oldLength); + } else if (delta < 0) { + target.copyWithin(start + items.length, start + remove, oldLength); + target.length = oldLength + delta; + } + for (let index = 0; index < items.length; index++) + defineData2(target, start + index, items[index]); +} +function permuteTrusted(target, permutation) { + if (target.length !== permutation.length) + throw new TypeError("Trusted permutation length mismatch"); + const previous = target.slice(); + for (let index = 0; index < permutation.length; index++) + target[index] = previous[permutation[index]]; +} +function isContainer(value2) { + return value2 !== null && typeof value2 === "object"; +} + +// node_modules/@earendil-works/chord/dist/delta/tracker.js +var NODE = /* @__PURE__ */ Symbol("chord.delta.overlay.node"); +var PREPARED = /* @__PURE__ */ new WeakMap(); +var RELEASED = {}; +var ARRAY_MUTATORS = /* @__PURE__ */ new Set([ + "push", + "pop", + "shift", + "unshift", + "splice", + "reverse", + "sort", + "fill", + "copyWithin" +]); +var MAX_DELTA_OPERATIONS = 4096; +var MAX_SIMPLE_OBJECT_NODES = 128; +var DATA_DESCRIPTOR3 = { + value: void 0, + writable: true, + enumerable: true, + configurable: true +}; +var PreparedImpl = class { + #context; + value; + base; + ops; + constructor(context, value2, operations) { + this.#context = context; + this.value = value2; + this.base = context.baseValue; + this.ops = operations; + PREPARED.set(this, context); + } + get baseRevision() { + return this.#context.baseRevision; + } + abort() { + abortContext(this.#context); + } +}; +var ChangeImpl = class { + #context; + #preparedStatus; + #settled = false; + constructor(context) { + this.#context = context; + } + get state() { + const state = this.#context?.root?.proxy; + if (state === void 0) + throw new TypeError("Cannot use a settled overlay"); + return state; + } + prepare() { + if (this.#settled) + throw new Error("Change has already been settled"); + const context = this.#context; + assertWritable(context); + context.status.value = "prepared"; + try { + if (!context.replacement) + context.ops = context.dirty.length === 0 ? [] : emitOperations(context); + const prepared = materializePrepared(context); + this.#preparedStatus = context.status; + this.#settled = true; + return prepared; + } catch (error) { + context.status.value = "aborted"; + clearContext(context); + this.#settled = true; + throw error; + } finally { + this.#context = void 0; + } + } + abort() { + if (this.#settled) { + if (this.#preparedStatus?.value === "prepared") + this.#preparedStatus.value = "aborted"; + this.#preparedStatus = void 0; + return; + } + this.#settled = true; + abortContext(this.#context); + this.#context = void 0; + } +}; +var TrackerImpl = class { + #owner = {}; + #open; + #competitors; + #selfRef; + #value; + #revision = 0; + #pruneBudget = 256; + constructor(initial) { + this.#value = initial; + this.#selfRef = new WeakRef(this); + } + get value() { + return this.#value; + } + get revision() { + return this.#revision; + } + beginChange() { + const context = createContext(this.#selfRef, this.#owner, this.#revision, this.#value, false, this.#value); + if (this.#open !== void 0) + this.#demote(this.#open); + this.#open = context; + return new ChangeImpl(context); + } + prepareReplace(value2) { + const context = createContext(this.#selfRef, this.#owner, this.#revision, value2, true, this.#value); + context.status.value = "prepared"; + try { + return materializePrepared(context); + } catch (error) { + context.status.value = "aborted"; + clearContext(context); + throw error; + } + } + adopt(prepared) { + const context = PREPARED.get(prepared); + if (context?.owner !== this.#owner) + throw new Error("Prepared change belongs to a different tracker"); + if (context.status.value === "consumed") + throw new Error("Prepared change has already been used"); + if (context.status.value === "aborted") + throw new Error("Prepared change has been aborted"); + if (context.status.value === "stale") + throw new Error("Prepared change is stale"); + if (context.status.value !== "prepared") + throw new Error("Prepared change is not ready"); + if (context.baseRevision !== this.#revision) { + context.status.value = "stale"; + clearContext(context); + throw new Error("Prepared change is stale"); + } + if (this.#value !== prepared.base) { + context.status.value = "stale"; + throw new Error("Prepared change is stale"); + } + this.#value = prepared.value; + context.status.value = "consumed"; + this.#revision += 1; + this.#invalidate(context); + } + releaseContext(context) { + if (this.#open === context) + this.#open = void 0; + else if (context.registryRef !== void 0) + this.#competitors?.delete(context.registryRef); + context.registryRef = void 0; + } + #demote(context) { + const reference = new WeakRef(context); + context.registryRef = reference; + this.#competitors ??= /* @__PURE__ */ new Set(); + this.#competitors.add(reference); + this.#pruneBudget -= 1; + if (this.#pruneBudget === 0) { + for (const entry2 of this.#competitors) { + if (entry2.deref() === void 0) + this.#competitors.delete(entry2); + } + this.#pruneBudget = Math.max(256, this.#competitors.size); + } + } + #invalidate(winner) { + const open = this.#open; + const competitors = this.#competitors; + this.#open = void 0; + this.#competitors = void 0; + this.#pruneBudget = 256; + if (open !== void 0) + staleCompetitor(open, winner); + if (competitors === void 0) + return; + for (const reference of competitors) { + const context = reference.deref(); + if (context !== void 0) + staleCompetitor(context, winner); + } + } + replacementBase(context) { + return context.baseRevision === this.#revision ? this.#value : void 0; + } +}; +function staleCompetitor(context, winner) { + if (context === winner) + return; + if (context.status.value === "open" || context.status.value === "prepared") + context.status.value = "stale"; + context.registryRef = void 0; + releaseOverlayReferences(context); +} +function track(initial) { + return new TrackerImpl(initial); +} +function appendText(draft, key, text) { + const node = draft[NODE]; + if (node === void 0) + throw new TypeError("appendText() needs a draft"); + assertWritable(node.context); + const name = String(key); + const current = Array.isArray(nodeBase(node)) || !objectHas(node, name) ? void 0 : objectValue(node, name); + if (typeof current !== "string") + throw new TypeError(`appendText() needs a string at ${JSON.stringify(name)}`); + if (text.length === 0) + return; + const owned = ownedSlice2(text, 0); + const appended = node.appends?.get(name) ?? (hasObjectWrite(node, name) ? void 0 : ""); + setObjectWrite(node, name, current + owned); + markDirty(node); + if (appended !== void 0) { + node.appends ??= /* @__PURE__ */ new Map(); + node.appends.set(name, appended + owned); + } +} +function materializePrepared(context) { + const operations = ensureOperations(context); + const base = context.baseValue; + const value2 = operations.length === 0 ? base : context.simpleObjectMaterialization ? cloneNode(context.root) : materializeOperations(base, operations); + const prepared = new PreparedImpl(context, value2, operations); + if (context.nodes.length > 4096) + releaseOverlayReferences(context); + else + clearContext(context); + return prepared; +} +function materializeOperations(base, operations) { + for (const operation of operations) { + if (operation[0] === "p" || operation[0] === "m") + return applyImmutable(base, operations); + } + return applyImmutableTrusted(base, operations); +} +function createContext(tracker, owner, baseRevision, root2, replacement, base) { + const context = { + owner, + tracker, + baseRevision, + status: { value: "open" }, + root: void 0, + bases: [], + stored: void 0, + dirty: [], + nodes: [], + rawNodes: void 0, + ops: void 0, + replacement, + replacementNoop: false, + baseValue: base, + overlayReleased: false, + simpleObjectMaterialization: false, + registryRef: void 0 + }; + context.root = createNode(context, root2, void 0); + return context; +} +var sharedObjectHandler = { + deleteProperty(target, property) { + return deleteProperty(nodeForTarget(target), property); + }, + defineProperty(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Defining overlay properties is not supported"); + }, + get(target, property) { + if (property === "then") { + const node2 = target[NODE]; + if (node2 === void 0 || isSettledContext(node2.context)) + return void 0; + } + const node = nodeForTarget(target); + if (property === NODE) + return node; + return getProperty(node, property); + }, + getOwnPropertyDescriptor(target, property) { + return getDescriptor(nodeForTarget(target), property); + }, + getPrototypeOf(target) { + const node = nodeForTarget(target); + assertReadable(node.context); + return Object.getPrototypeOf(nodeBase(node)); + }, + has(target, property) { + return hasProperty(nodeForTarget(target), property); + }, + isExtensible(target) { + assertReadable(nodeForTarget(target).context); + return true; + }, + ownKeys(target) { + return ownKeys(nodeForTarget(target)); + }, + preventExtensions(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Overlays cannot be made non-extensible"); + }, + set(target, property, value2) { + return setProperty(nodeForTarget(target), property, value2); + }, + setPrototypeOf(target) { + assertWritable(nodeForTarget(target).context); + throw new TypeError("Changing an overlay prototype is not supported"); + } +}; +var sharedArrayHandler = sharedObjectHandler; +function createNode(context, base, parent, parentKind = 0, parentKey = "", parentSource = void 0, parentPlacement = false) { + const existing = context.rawNodes?.get(base); + if (existing !== void 0) + return existing; + const target = Array.isArray(base) ? [] : {}; + const baseIndex = context.bases.length; + const parentIndex = parent?.baseIndex ?? -1; + context.bases.push(base); + const node = { + context, + baseIndex, + parentIndex, + parentKind, + parentKey, + parentPlacement, + target, + proxy: target + }; + if (parentSource !== void 0) + node.parentSource = parentSource; + target[NODE] = node; + node.proxy = new Proxy(target, Array.isArray(base) ? sharedArrayHandler : sharedObjectHandler); + if (parent !== void 0) { + context.rawNodes ??= /* @__PURE__ */ new WeakMap(); + context.rawNodes.set(base, node); + } + context.nodes.push(node); + return node; +} +function nodeBase(node) { + return node.context.bases[node.baseIndex]; +} +function nodeParent(node) { + return node.parentIndex < 0 ? void 0 : node.context.nodes[node.parentIndex]; +} +function storeValue(context, value2) { + if (!isContainer2(value2)) + return value2; + context.stored ??= []; + const index = context.stored.length; + context.stored.push(value2); + return { index }; +} +function storedValue(context, reference) { + return typeof reference === "object" && reference !== null ? context.stored[reference.index] : reference; +} +function replaceStoredValue(context, reference, value2) { + if (isContainer2(value2)) { + if (typeof reference === "object" && reference !== null) { + context.stored[reference.index] = value2; + return reference; + } + return storeValue(context, value2); + } + if (typeof reference === "object" && reference !== null) + context.stored[reference.index] = RELEASED; + return value2; +} +function releaseStoredValue(context, reference) { + if (typeof reference === "object" && reference !== null) + context.stored[reference.index] = RELEASED; +} +function nodeForTarget(target) { + const node = target[NODE]; + if (node === void 0) + throw new TypeError("Cannot use a settled overlay"); + return node; +} +function isSettledContext(context) { + return context.overlayReleased || context.status.value === "consumed" || context.status.value === "aborted" || context.status.value === "stale"; +} +function assertReadable(context) { + if (isSettledContext(context)) + throw new TypeError("Cannot use a settled overlay"); +} +function assertWritable(context) { + assertReadable(context); + if (context.status.value !== "open") + throw new TypeError("Prepared overlays are read-only"); +} +function getProperty(node, property) { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + if (property === "length") + return arrayLength(overlay); + if (ARRAY_MUTATORS.has(property)) + return arrayMutators[property]; + const index = arrayIndex(property); + if (index !== void 0) + return getArrayIndex(node, index); + return Reflect.get(Array.prototype, property, node.proxy); + } + if (typeof property === "symbol") + return Reflect.get(nodeBase(node), property, node.proxy); + const key = String(property); + if (!objectHas(node, key)) { + if (isObjectDeleted(node, key) || Object.hasOwn(nodeBase(node), key)) + return void 0; + return Reflect.get(nodeBase(node), property, node.proxy); + } + const value2 = objectValue(node, key); + if (!isContainer2(value2)) + return value2; + return createNode(node.context, value2, node, 0, key, void 0, hasObjectWrite(node, key)).proxy; +} +function getArrayIndex(node, index) { + const overlay = arrayOverlay(node); + if (index >= arrayLength(overlay)) + return void 0; + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const value2 = entryValueAt(node, piece, sourceIndex); + if (!isContainer2(value2)) + return value2; + return createNode(node.context, value2, node, piece.kind === "base" ? 1 : 2, sourceIndex, piece.kind === "insert" ? piece.source : void 0, piece.kind === "insert" || hasEntryOverrideAt(overlay, piece, sourceIndex)).proxy; +} +function setProperty(node, property, supplied) { + assertWritable(node.context); + if (typeof property === "symbol") + throw new TypeError("Symbol writes are not supported"); + if (Array.isArray(nodeBase(node))) { + if (property === "length") { + setArrayLength(node, toArrayLength(supplied)); + return true; + } + const index = arrayIndex(property); + if (index === void 0) + throw new TypeError("Only array indices and length can be written"); + if (index > arrayLength(arrayOverlay(node))) + throw new TypeError("Overlay arrays cannot contain holes"); + setArrayIndex(node, index, clonePlacement(supplied)); + return true; + } + const key = String(property); + if (supplied === void 0) + return deleteProperty(node, key); + const stored = clonePlacement(supplied); + const current = objectValue(node, key); + const wasDeleted = isObjectDeleted(node, key); + if (!wasDeleted && !isContainer2(stored) && current === stored) + return true; + node.appends?.delete(key); + setObjectWrite(node, key, stored); + if (wasDeleted && Object.hasOwn(nodeBase(node), key)) { + if (node.readded === void 0) + node.readded = /* @__PURE__ */ new Set(); + node.readded.add(key); + } + deleteObjectDeletion(node, key); + markDirty(node); + return true; +} +function deleteProperty(node, property) { + assertWritable(node.context); + if (typeof property === "symbol") + throw new TypeError("Symbol writes are not supported"); + if (Array.isArray(nodeBase(node))) + throw new TypeError("Overlay arrays cannot contain holes"); + const key = String(property); + if (!objectHas(node, key)) + return true; + node.appends?.delete(key); + deleteObjectWrite(node, key); + node.readded?.delete(key); + setObjectDeletion(node, key); + markDirty(node); + return true; +} +function hasProperty(node, property) { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + if (property === "length") + return true; + const index = arrayIndex(property); + if (index !== void 0) + return index < arrayLength(arrayOverlay(node)); + return property in Array.prototype; + } + return typeof property === "symbol" ? property in nodeBase(node) : objectHas(node, String(property)); +} +function ownKeys(node) { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const length = arrayLength(arrayOverlay(node)); + const keys2 = new Array(length + 1); + for (let index = 0; index < length; index++) + keys2[index] = String(index); + keys2[length] = "length"; + return keys2; + } + let existingKeysOnly = node.deleteKey === void 0 && node.deletes === void 0 && node.readded === void 0 && (node.writeKey === void 0 || Object.hasOwn(nodeBase(node), node.writeKey)); + if (existingKeysOnly && node.writes !== void 0) { + for (const key of node.writes.keys()) { + if (!Object.hasOwn(nodeBase(node), key)) { + existingKeysOnly = false; + break; + } + } + } + if (existingKeysOnly) + return Object.keys(nodeBase(node)); + const keys = Object.keys(nodeBase(node)).filter((key) => !isObjectDeleted(node, key) && !node.readded?.has(key)); + const seen = new Set(keys); + if (node.writeKey !== void 0 && !seen.has(node.writeKey)) { + keys.push(node.writeKey); + seen.add(node.writeKey); + } + if (node.writes !== void 0) { + for (const key of node.writes.keys()) { + if (seen.has(key)) + continue; + keys.push(key); + seen.add(key); + } + } + const indices = []; + const strings = []; + for (const key of keys) { + const index = arrayIndex(key); + if (index === void 0) + strings.push(key); + else + indices.push(index); + } + indices.sort((left, right) => left - right); + return [...indices.map(String), ...strings]; +} +function getDescriptor(node, property) { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + if (property === "length") { + const length = arrayLength(arrayOverlay(node)); + node.target.length = length; + return Reflect.getOwnPropertyDescriptor(node.target, "length"); + } + const index = arrayIndex(property); + if (index === void 0 || index >= arrayLength(arrayOverlay(node))) + return void 0; + } else { + if (typeof property === "symbol" || !objectHas(node, String(property))) + return void 0; + } + return { + configurable: true, + enumerable: true, + writable: node.context.status.value === "open", + value: getProperty(node, property) + }; +} +function hasObjectWrite(node, key) { + return node.writeKey === key || (node.writes?.has(key) ?? false); +} +function setObjectWrite(node, key, value2) { + if (node.writes !== void 0) { + const reference = node.writes.get(key); + node.writes.set(key, reference === void 0 ? storeValue(node.context, value2) : replaceStoredValue(node.context, reference, value2)); + return; + } + if (node.writeKey === void 0 || node.writeKey === key) { + node.writeKey = key; + node.writeValue = node.writeValue === void 0 ? storeValue(node.context, value2) : replaceStoredValue(node.context, node.writeValue, value2); + return; + } + node.writes = /* @__PURE__ */ new Map(); + node.writes.set(node.writeKey, node.writeValue); + node.writes.set(key, storeValue(node.context, value2)); + node.writeKey = void 0; + node.writeValue = void 0; +} +function deleteObjectWrite(node, key) { + if (node.writeKey === key) { + node.writeKey = void 0; + releaseStoredValue(node.context, node.writeValue); + node.writeValue = void 0; + } else { + const reference = node.writes?.get(key); + if (reference !== void 0) + releaseStoredValue(node.context, reference); + node.writes?.delete(key); + } +} +function isObjectDeleted(node, key) { + return node.deleteKey === key || (node.deletes?.has(key) ?? false); +} +function setObjectDeletion(node, key) { + if (node.deletes !== void 0) { + node.deletes.add(key); + return; + } + if (node.deleteKey === void 0 || node.deleteKey === key) { + node.deleteKey = key; + return; + } + node.deletes = /* @__PURE__ */ new Set(); + node.deletes.add(node.deleteKey); + node.deletes.add(key); + node.deleteKey = void 0; +} +function deleteObjectDeletion(node, key) { + if (node.deleteKey === key) + node.deleteKey = void 0; + else + node.deletes?.delete(key); +} +function objectHas(node, key) { + if (isObjectDeleted(node, key)) + return false; + return hasObjectWrite(node, key) || Object.hasOwn(nodeBase(node), key); +} +function objectValue(node, key) { + if (node.writeKey === key) + return storedValue(node.context, node.writeValue); + const reference = node.writes?.get(key); + if (reference !== void 0) + return storedValue(node.context, reference); + return nodeBase(node)[key]; +} +function markDirty(node) { + if (node.dirty) + return; + node.dirty = true; + node.context.dirty.push(node); + for (let parent = nodeParent(node); parent !== void 0; parent = nodeParent(parent)) + parent.subtreeDirty = true; +} +function arrayOverlay(node) { + if (node.array !== void 0) + return node.array; + const base = nodeBase(node); + const overlay = { + root: void 0, + pieces: void 0, + baseOverrides: void 0, + insertOverrides: void 0, + structural: false, + generation: 0, + plan: void 0, + seed: 2654435769, + locatedOffset: 0, + baseLocations: void 0, + insertLocations: void 0 + }; + if (base.length > 0) + overlay.root = createPieceNode(overlay, { kind: "base", start: 0, length: base.length, step: 1 }); + node.array = overlay; + return overlay; +} +function baseOverridesForWrite(overlay) { + if (overlay.baseOverrides === void 0) + overlay.baseOverrides = /* @__PURE__ */ new Map(); + return overlay.baseOverrides; +} +function insertOverridesForWrite(overlay) { + if (overlay.insertOverrides === void 0) + overlay.insertOverrides = /* @__PURE__ */ new Map(); + return overlay.insertOverrides; +} +function nextPiecePriority(overlay) { + let value2 = overlay.seed; + value2 ^= value2 << 13; + value2 ^= value2 >>> 17; + value2 ^= value2 << 5; + overlay.seed = value2 >>> 0; + return overlay.seed; +} +function treeElements(node) { + return node?.elements ?? 0; +} +function updatePieceNode(node) { + node.elements = treeElements(node.left) + node.piece.length + treeElements(node.right); +} +function createPieceNode(overlay, piece) { + return { piece, left: void 0, right: void 0, priority: nextPiecePriority(overlay), elements: piece.length }; +} +function mergePieceTrees(left, right) { + if (left === void 0) + return right; + if (right === void 0) + return left; + if (left.priority >= right.priority) { + left.right = mergePieceTrees(left.right, right); + updatePieceNode(left); + return left; + } + right.left = mergePieceTrees(left, right.left); + updatePieceNode(right); + return right; +} +function splitPieceTree(overlay, root2, index) { + if (root2 === void 0) + return [void 0, void 0]; + const leftLength = treeElements(root2.left); + if (index < leftLength) { + const [left, right] = splitPieceTree(overlay, root2.left, index); + root2.left = right; + updatePieceNode(root2); + return [left, root2]; + } + const pieceEnd = leftLength + root2.piece.length; + if (index > pieceEnd) { + const [left, right] = splitPieceTree(overlay, root2.right, index - pieceEnd); + root2.right = left; + updatePieceNode(root2); + return [root2, right]; + } + if (index === leftLength) { + const left = root2.left; + root2.left = void 0; + updatePieceNode(root2); + return [left, root2]; + } + if (index === pieceEnd) { + const right = root2.right; + root2.right = void 0; + updatePieceNode(root2); + return [root2, right]; + } + const offset = index - leftLength; + const first = { ...root2.piece, length: offset }; + const second = { + ...root2.piece, + start: root2.piece.start + root2.piece.step * offset, + length: root2.piece.length - offset + }; + return [ + mergePieceTrees(root2.left, createPieceNode(overlay, first)), + mergePieceTrees(createPieceNode(overlay, second), root2.right) + ]; +} +function leftmostPieceNode(node) { + while (node.left !== void 0) + node = node.left; + return node; +} +function rightmostPieceNode(node) { + while (node.right !== void 0) + node = node.right; + return node; +} +function mergeablePieces(left, right) { + if (left.kind !== right.kind) + return false; + if (left.kind === "insert" && left.source !== right.source) + return false; + if (left.length === 1 && right.length === 1) + return Math.abs(right.start - left.start) === 1; + return left.step === right.step && left.start + left.step * left.length === right.start; +} +function joinNormalized(overlay, left, right) { + if (left === void 0) + return right; + if (right === void 0) + return left; + const leftPiece = rightmostPieceNode(left).piece; + const rightPiece = leftmostPieceNode(right).piece; + if (!mergeablePieces(leftPiece, rightPiece)) + return mergePieceTrees(left, right); + const [leftRest] = splitPieceTree(overlay, left, treeElements(left) - leftPiece.length); + const [, rightRest] = splitPieceTree(overlay, right, rightPiece.length); + const step = leftPiece.length === 1 ? rightPiece.start - leftPiece.start : leftPiece.step; + const combined = leftPiece.kind === "base" ? { kind: "base", start: leftPiece.start, length: leftPiece.length + rightPiece.length, step } : { + kind: "insert", + source: leftPiece.source, + start: leftPiece.start, + length: leftPiece.length + rightPiece.length, + step + }; + return joinNormalized(overlay, joinNormalized(overlay, leftRest, createPieceNode(overlay, combined)), rightRest); +} +function flattenPieceTree(node, output) { + if (node === void 0) + return; + flattenPieceTree(node.left, output); + output.push(node.piece); + flattenPieceTree(node.right, output); +} +function piecesOf(overlay) { + if (overlay.pieces === void 0) { + overlay.pieces = []; + flattenPieceTree(overlay.root, overlay.pieces); + } + return overlay.pieces; +} +function treeFromPieces(overlay, pieces) { + mergePieces(pieces); + let root2; + for (const piece of pieces) + root2 = mergePieceTrees(root2, createPieceNode(overlay, piece)); + return root2; +} +function replaceAllPieces(overlay, pieces) { + overlay.root = treeFromPieces(overlay, pieces); + overlay.pieces = void 0; + overlay.baseLocations = void 0; + overlay.insertLocations = void 0; +} +function arrayLength(overlay) { + return treeElements(overlay.root); +} +function locatePiece(overlay, index) { + let node = overlay.root; + while (node !== void 0) { + const leftLength = treeElements(node.left); + if (index < leftLength) + node = node.left; + else if (index >= leftLength + node.piece.length) { + index -= leftLength + node.piece.length; + node = node.right; + } else { + overlay.locatedOffset = index - leftLength; + return node.piece; + } + } + throw new RangeError("Array overlay index is out of range"); +} +function arrayIndex(property) { + if (typeof property !== "string" || property.length === 0 || property.length > 10) + return void 0; + if (property === "0") + return 0; + const first = property.charCodeAt(0); + if (first < 49 || first > 57) + return void 0; + let index = first - 48; + for (let offset = 1; offset < property.length; offset++) { + const digit = property.charCodeAt(offset) - 48; + if (digit < 0 || digit > 9) + return void 0; + index = index * 10 + digit; + if (index >= 4294967295) + return void 0; + } + return index; +} +function entryValueAt(node, piece, sourceIndex) { + const overlay = arrayOverlay(node); + if (piece.kind === "base") { + const valueIndex = overlay.baseOverrides?.get(sourceIndex); + return valueIndex === void 0 ? nodeBase(node)[sourceIndex] : storedValue(node.context, valueIndex); + } + const overrides = overlay.insertOverrides?.get(piece.source); + const reference = overrides?.has(sourceIndex) ? overrides.get(sourceIndex) : piece.source.refs[sourceIndex]; + return storedValue(node.context, reference); +} +function hasEntryOverrideAt(overlay, piece, sourceIndex) { + return piece.kind === "base" ? overlay.baseOverrides?.has(sourceIndex) ?? false : overlay.insertOverrides?.get(piece.source)?.has(sourceIndex) ?? false; +} +function extendRightmostPiece(node, amount) { + if (node.right !== void 0) + extendRightmostPiece(node.right, amount); + else + node.piece.length += amount; + updatePieceNode(node); +} +function invalidatePieceCaches(overlay) { + overlay.pieces = void 0; + overlay.baseLocations = void 0; + overlay.insertLocations = void 0; + overlay.plan = void 0; +} +function replacePieceRange(node, index, remove, inserted) { + if (remove === 0 && inserted.length === 0) + return; + const overlay = arrayOverlay(node); + if (remove === 0 && index === arrayLength(overlay) && inserted.length === 1 && overlay.root !== void 0) { + const addition = inserted[0]; + const tail = rightmostPieceNode(overlay.root).piece; + if (addition.kind === "insert" && tail.kind === "insert" && tail.step === 1 && tail.start + tail.length === tail.source.refs.length) { + for (let offset = 0; offset < addition.length; offset++) { + tail.source.refs.push(addition.source.refs[addition.start + offset]); + } + extendRightmostPiece(overlay.root, addition.length); + invalidatePieceCaches(overlay); + overlay.structural = true; + overlay.generation += 1; + markDirty(node); + return; + } + } + const [left, rest] = splitPieceTree(overlay, overlay.root, index); + const [, right] = splitPieceTree(overlay, rest, remove); + const middle = treeFromPieces(overlay, inserted); + overlay.root = joinNormalized(overlay, joinNormalized(overlay, left, middle), right); + invalidatePieceCaches(overlay); + overlay.structural = true; + overlay.generation += 1; + markDirty(node); +} +function mergePieces(pieces) { + for (let index = 1; index < pieces.length; ) { + const left = pieces[index - 1]; + const right = pieces[index]; + const sameSource = left.kind === right.kind && (left.kind === "base" || left.source === right.source); + if (sameSource && left.length === 1 && right.length === 1 && Math.abs(right.start - left.start) === 1) { + left.step = right.start - left.start; + left.length = 2; + pieces.splice(index, 1); + } else if (sameSource && left.step === right.step && left.start + left.step * left.length === right.start) { + left.length += right.length; + pieces.splice(index, 1); + } else + index += 1; + } +} +function insertPiece(node, items, start = 0) { + if (items.length === start) + return []; + const refs = []; + for (let index = start; index < items.length; index++) + refs.push(storeValue(node.context, items[index])); + return [{ kind: "insert", source: { refs }, start: 0, length: refs.length, step: 1 }]; +} +function insertPlacementPiece(node, values, start = 0) { + if (values.length === start) + return []; + const storedLength = node.context.stored?.length ?? 0; + try { + for (let index = start; index < values.length; index++) + values[index] = storeValue(node.context, clonePlacement(values[index])); + } catch (error) { + if (node.context.stored !== void 0) + node.context.stored.length = storedLength; + throw error; + } + return [ + { + kind: "insert", + source: { refs: values }, + start, + length: values.length - start, + step: 1 + } + ]; +} +function setArrayIndex(node, index, stored) { + const overlay = arrayOverlay(node); + const length = arrayLength(overlay); + if (index === length) { + replacePieceRange(node, length, 0, insertPiece(node, [stored])); + return; + } + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const current = entryValueAt(node, piece, sourceIndex); + if (!isContainer2(stored) && current === stored) + return; + if (piece.kind === "base") { + const existingIndex = overlay.baseOverrides?.get(sourceIndex); + if (!isContainer2(stored) && stored === nodeBase(node)[sourceIndex]) { + if (existingIndex !== void 0) + releaseStoredValue(node.context, existingIndex); + overlay.baseOverrides?.delete(sourceIndex); + } else if (existingIndex === void 0) { + baseOverridesForWrite(overlay).set(sourceIndex, storeValue(node.context, stored)); + } else + overlay.baseOverrides.set(sourceIndex, replaceStoredValue(node.context, existingIndex, stored)); + } else { + let overrides = overlay.insertOverrides?.get(piece.source); + const existingIndex = overrides?.get(sourceIndex); + if (!isContainer2(stored) && stored === storedValue(node.context, piece.source.refs[sourceIndex])) { + if (existingIndex !== void 0) + releaseStoredValue(node.context, existingIndex); + overrides?.delete(sourceIndex); + if (overrides?.size === 0) + overlay.insertOverrides?.delete(piece.source); + } else { + if (overrides === void 0) { + overrides = /* @__PURE__ */ new Map(); + insertOverridesForWrite(overlay).set(piece.source, overrides); + } + if (existingIndex === void 0) + overrides.set(sourceIndex, storeValue(node.context, stored)); + else + overrides.set(sourceIndex, replaceStoredValue(node.context, existingIndex, stored)); + } + } + markDirty(node); +} +function setArrayLength(node, next) { + const current = arrayLength(arrayOverlay(node)); + if (next === current) + return; + if (next < current) + replacePieceRange(node, next, current - next, []); + else + replacePieceRange(node, current, 0, insertPiece(node, Array.from({ length: next - current }, () => null))); +} +function toArrayLength(value2) { + const number2 = Number(value2); + if (!Number.isInteger(number2) || number2 < 0 || number2 >= 4294967296) + throw new RangeError("Invalid array length"); + return number2; +} +function toIntegerOrInfinity(value2) { + const number2 = Number(value2); + if (Number.isNaN(number2) || number2 === 0) + return 0; + return Number.isFinite(number2) ? Math.trunc(number2) : number2; +} +function clampIndex(value2, length) { + if (value2 === Number.NEGATIVE_INFINITY) + return 0; + if (value2 < 0) + return Math.max(length + value2, 0); + return Math.min(value2, length); +} +function mutatorNode(receiver) { + if (!isContainer2(receiver)) + return void 0; + const node = Reflect.get(receiver, NODE); + if (node === void 0) + return void 0; + if (!Array.isArray(nodeBase(node))) + throw new TypeError("Array mutator called on incompatible receiver"); + assertWritable(node.context); + return node; +} +var arrayMutators = { + push(...items) { + const node = mutatorNode(this); + if (node === void 0) + return Reflect.apply(Array.prototype.push, this, items); + const length = arrayLength(arrayOverlay(node)); + replacePieceRange(node, length, 0, insertPlacementPiece(node, items)); + return length + items.length; + }, + pop() { + const node = mutatorNode(this); + if (node === void 0) + return Reflect.apply(Array.prototype.pop, this, []); + const length = arrayLength(arrayOverlay(node)); + if (length === 0) + return void 0; + const value2 = getArrayIndex(node, length - 1); + replacePieceRange(node, length - 1, 1, []); + return value2; + }, + shift() { + const node = mutatorNode(this); + if (node === void 0) + return Reflect.apply(Array.prototype.shift, this, []); + const length = arrayLength(arrayOverlay(node)); + if (length === 0) + return void 0; + const value2 = getArrayIndex(node, 0); + replacePieceRange(node, 0, 1, []); + return value2; + }, + unshift(...items) { + const node = mutatorNode(this); + if (node === void 0) + return Reflect.apply(Array.prototype.unshift, this, items); + replacePieceRange(node, 0, 0, insertPlacementPiece(node, items)); + return arrayLength(arrayOverlay(node)); + }, + splice(...args) { + const node = mutatorNode(this); + if (node === void 0) + return Reflect.apply(Array.prototype.splice, this, args); + const length = arrayLength(arrayOverlay(node)); + const start = args.length === 0 ? 0 : clampIndex(toIntegerOrInfinity(args[0]), length); + const remove = args.length === 0 ? 0 : args.length === 1 ? length - start : Math.min(Math.max(toIntegerOrInfinity(args[1]), 0), length - start); + const removed = Array.from({ length: remove }, (_, offset) => getArrayIndex(node, start + offset)); + const itemStart = Math.min(2, args.length); + const itemCount = Math.max(args.length - 2, 0); + replacePieceRange(node, start, remove, insertPlacementPiece(node, args, itemStart)); + setArrayLength(node, length - remove + itemCount); + return removed; + }, + reverse() { + const node = mutatorNode(this); + if (node === void 0) + return Reflect.apply(Array.prototype.reverse, this, []); + const overlay = arrayOverlay(node); + if (arrayLength(overlay) < 2) + return node.proxy; + const pieces = [...piecesOf(overlay)].reverse(); + for (const piece of pieces) { + piece.start += piece.step * (piece.length - 1); + piece.step = piece.step === 1 ? -1 : 1; + } + replaceAllPieces(overlay, pieces); + overlay.structural = true; + overlay.generation += 1; + overlay.plan = void 0; + markDirty(node); + return node.proxy; + }, + sort(comparator) { + const node = mutatorNode(this); + if (node === void 0) + return Reflect.apply(Array.prototype.sort, this, [comparator]); + if (comparator !== void 0 && typeof comparator !== "function") + throw new TypeError("Comparator must be a function"); + const overlay = arrayOverlay(node); + const insertedSources = []; + const insertedIndices = []; + const insertedValues = []; + const baseValues = new Array(nodeBase(node).length); + const order = []; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + if (piece.kind === "base") { + order.push(sourceIndex); + baseValues[sourceIndex] = publicSortValue(node, sourceIndex, insertedSources, insertedIndices); + } else { + insertedSources.push(piece.source); + insertedIndices.push(sourceIndex); + const token = -insertedSources.length; + order.push(token); + insertedValues.push(publicSortValue(node, token, insertedSources, insertedIndices)); + } + } + } + const baseSnapshot = /* @__PURE__ */ new Map(); + for (const [index, valueIndex] of overlay.baseOverrides ?? []) + baseSnapshot.set(index, storedValue(node.context, valueIndex)); + const insertSnapshots = /* @__PURE__ */ new Map(); + for (const [source, overrides] of overlay.insertOverrides ?? []) { + const snapshot = /* @__PURE__ */ new Map(); + for (const [index, valueIndex] of overrides) + snapshot.set(index, storedValue(node.context, valueIndex)); + insertSnapshots.set(source, snapshot); + } + const generation = overlay.generation; + order.sort((left, right) => { + const leftValue = left < 0 ? insertedValues[-left - 1] : baseValues[left]; + const rightValue = right < 0 ? insertedValues[-right - 1] : baseValues[right]; + if (typeof comparator === "function") + return Number(comparator(leftValue, rightValue)); + const a = String(leftValue); + const b = String(rightValue); + return a < b ? -1 : a > b ? 1 : 0; + }); + if (baseSnapshot.size > 0 || insertSnapshots.size > 0 || (overlay.baseOverrides?.size ?? 0) > 0 || (overlay.insertOverrides?.size ?? 0) > 0) { + for (const token of order) { + restoreSortOverride(node, token, insertedSources, insertedIndices, baseSnapshot, insertSnapshots); + } + } + const comparatorWasStructural = overlay.generation !== generation; + const currentLength = arrayLength(overlay); + const samePrefix = currentLength >= order.length && order.every((token, index) => sameSortTokenAt(overlay, index, token, insertedSources, insertedIndices)); + if (!samePrefix) { + replacePieceRange(node, 0, Math.min(order.length, currentLength), piecesFromSortOrder(order, insertedSources, insertedIndices)); + } + if (comparatorWasStructural) + deduplicateArrayEntries(node); + return node.proxy; + }, + fill(supplied, startArg, endArg) { + const node = mutatorNode(this); + if (node === void 0) + return Reflect.apply(Array.prototype.fill, this, [supplied, startArg, endArg]); + const length = arrayLength(arrayOverlay(node)); + const start = startArg === void 0 ? 0 : clampIndex(toIntegerOrInfinity(startArg), length); + const end = endArg === void 0 ? length : clampIndex(toIntegerOrInfinity(endArg), length); + if (end <= start) + return node.proxy; + const items = Array.from({ length: end - start }, () => clonePlacement(supplied)); + replacePieceRange(node, start, end - start, insertPiece(node, items)); + return node.proxy; + }, + copyWithin(targetArg, startArg, endArg) { + const node = mutatorNode(this); + if (node === void 0) + return Reflect.apply(Array.prototype.copyWithin, this, [targetArg, startArg, endArg]); + const length = arrayLength(arrayOverlay(node)); + const target = clampIndex(toIntegerOrInfinity(targetArg), length); + const start = clampIndex(toIntegerOrInfinity(startArg), length); + const end = endArg === void 0 ? length : clampIndex(toIntegerOrInfinity(endArg), length); + const count = Math.min(Math.max(end - start, 0), length - target); + const values = Array.from({ length: count }, (_, offset) => clonePlacement(getArrayIndex(node, start + offset))); + replacePieceRange(node, target, count, insertPiece(node, values)); + return node.proxy; + } +}; +function sortTokenSource(token, sources) { + return token < 0 ? sources[-token - 1] : void 0; +} +function sortTokenIndex(token, indices) { + return token < 0 ? indices[-token - 1] : token; +} +function publicSortValue(node, token, sources, indices) { + const overlay = arrayOverlay(node); + const source = sortTokenSource(token, sources); + const sourceIndex = sortTokenIndex(token, indices); + const overrideIndex = source === void 0 ? overlay.baseOverrides?.get(sourceIndex) : overlay.insertOverrides?.get(source)?.get(sourceIndex); + const value2 = overrideIndex !== void 0 ? storedValue(node.context, overrideIndex) : source === void 0 ? nodeBase(node)[sourceIndex] : storedValue(node.context, source.refs[sourceIndex]); + if (!isContainer2(value2)) + return value2; + return createNode(node.context, value2, node, source === void 0 ? 1 : 2, sourceIndex, source, source !== void 0 || (overlay.baseOverrides?.has(sourceIndex) ?? false)).proxy; +} +function restoreSortOverride(node, token, sources, indices, baseSnapshot, insertSnapshots) { + const overlay = arrayOverlay(node); + const source = sortTokenSource(token, sources); + const sourceIndex = sortTokenIndex(token, indices); + if (source === void 0) { + const existingIndex2 = overlay.baseOverrides?.get(sourceIndex); + const snapshotValue2 = baseSnapshot.get(sourceIndex); + if (snapshotValue2 !== void 0) { + if (existingIndex2 === void 0) + baseOverridesForWrite(overlay).set(sourceIndex, storeValue(node.context, snapshotValue2)); + else + overlay.baseOverrides.set(sourceIndex, replaceStoredValue(node.context, existingIndex2, snapshotValue2)); + } else { + if (existingIndex2 !== void 0) + releaseStoredValue(node.context, existingIndex2); + overlay.baseOverrides?.delete(sourceIndex); + } + return; + } + let overrides = overlay.insertOverrides?.get(source); + const existingIndex = overrides?.get(sourceIndex); + const snapshotValue = insertSnapshots.get(source)?.get(sourceIndex); + if (snapshotValue !== void 0) { + if (overrides === void 0) { + overrides = /* @__PURE__ */ new Map(); + insertOverridesForWrite(overlay).set(source, overrides); + } + if (existingIndex === void 0) + overrides.set(sourceIndex, storeValue(node.context, snapshotValue)); + else + overrides.set(sourceIndex, replaceStoredValue(node.context, existingIndex, snapshotValue)); + } else { + if (existingIndex !== void 0) + releaseStoredValue(node.context, existingIndex); + overrides?.delete(sourceIndex); + if (overrides?.size === 0) + overlay.insertOverrides?.delete(source); + } +} +function sameSortTokenAt(overlay, logicalIndex, token, sources, indices) { + const piece = locatePiece(overlay, logicalIndex); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + const tokenSource = sortTokenSource(token, sources); + return sourceIndex === sortTokenIndex(token, indices) && (piece.kind === "base" && tokenSource === void 0 || piece.kind === "insert" && piece.source === tokenSource); +} +function deduplicateArrayEntries(node) { + const overlay = arrayOverlay(node); + const seenBase = /* @__PURE__ */ new Set(); + const seenInsert = /* @__PURE__ */ new Map(); + const next = []; + let duplicated = false; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + let seen; + if (piece.kind === "base") { + seen = seenBase.has(sourceIndex); + seenBase.add(sourceIndex); + } else { + let indices = seenInsert.get(piece.source); + if (indices === void 0) { + indices = /* @__PURE__ */ new Set(); + seenInsert.set(piece.source, indices); + } + seen = indices.has(sourceIndex); + indices.add(sourceIndex); + } + if (seen) { + duplicated = true; + appendMergedPiece(next, insertPiece(node, [clonePlacementStored(entryValueAt(node, piece, sourceIndex), node.context)])[0]); + } else + appendMergedPiece(next, singletonPiece(piece, sourceIndex)); + } + } + if (!duplicated) + return; + replaceAllPieces(overlay, next); + overlay.structural = true; + overlay.generation += 1; + overlay.plan = void 0; + markDirty(node); +} +function piecesFromSortOrder(order, sources, indices) { + const pieces = []; + for (const token of order) { + const source = token < 0 ? sources[-token - 1] : void 0; + const sourceIndex = token < 0 ? indices[-token - 1] : token; + const previous = pieces.at(-1); + const sameSource = previous !== void 0 && (source === void 0 && previous.kind === "base" || source !== void 0 && previous.kind === "insert" && previous.source === source); + if (previous !== void 0 && sameSource) { + if (previous.length === 1) { + const step = sourceIndex - previous.start; + if (step === 1 || step === -1) { + previous.step = step; + previous.length = 2; + continue; + } + } else if (previous.start + previous.step * previous.length === sourceIndex) { + previous.length += 1; + continue; + } + } + pieces.push(source === void 0 ? { kind: "base", start: sourceIndex, length: 1, step: 1 } : { kind: "insert", source, start: sourceIndex, length: 1, step: 1 }); + } + return pieces; +} +function appendMergedPiece(pieces, piece) { + const previous = pieces.at(-1); + const sameSource = previous !== void 0 && previous.kind === piece.kind && (previous.kind === "base" || previous.source === piece.source); + if (previous !== void 0 && sameSource && previous.length === 1 && piece.length === 1) { + const step = piece.start - previous.start; + if (step === 1 || step === -1) { + previous.step = step; + previous.length = 2; + return; + } + } + if (previous !== void 0 && sameSource && previous.step === piece.step && previous.start + previous.step * previous.length === piece.start) { + previous.length += piece.length; + } else + pieces.push(piece); +} +function singletonPiece(piece, sourceIndex) { + return piece.kind === "base" ? { kind: "base", start: sourceIndex, length: 1, step: 1 } : { kind: "insert", source: piece.source, start: sourceIndex, length: 1, step: 1 }; +} +function clonePlacement(value2) { + const proxyNode = isContainer2(value2) ? Reflect.get(value2, NODE) : void 0; + return proxyNode === void 0 ? copyJson(value2) : clonePlacementNode(proxyNode); +} +function cloneNode(node) { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + const result2 = []; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + result2.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + } + return result2; + } + const result = Object.create(Object.getPrototypeOf(nodeBase(node))); + for (const key of ownKeys(node)) + defineData3(result, key, cloneStored(objectValue(node, key), node.context)); + return result; +} +function cloneStored(value2, context) { + if (!isContainer2(value2)) + return value2; + const node = context.rawNodes?.get(value2); + return node === void 0 || !node.dirty && !node.subtreeDirty ? value2 : cloneNode(node); +} +function clonePlacementNode(node) { + assertReadable(node.context); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + const result2 = []; + for (const piece of piecesOf(overlay)) { + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + result2.push(clonePlacementStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + } + return result2; + } + const result = Object.create(Object.getPrototypeOf(nodeBase(node))); + for (const key of ownKeys(node)) + defineData3(result, key, clonePlacementStored(objectValue(node, key), node.context)); + return result; +} +function clonePlacementStored(value2, context) { + if (!isContainer2(value2)) + return copyJson(value2); + const node = context.rawNodes?.get(value2); + return node === void 0 ? copyJson(value2) : clonePlacementNode(node); +} +function defineData3(target, key, value2) { + DATA_DESCRIPTOR3.value = value2; + Object.defineProperty(target, key, DATA_DESCRIPTOR3); + DATA_DESCRIPTOR3.value = void 0; +} +function emitOperations(context) { + const simple = emitSimpleObjectOperations(context); + if (simple !== void 0) + return simple; + const operations = []; + const forcedFolds = /* @__PURE__ */ new Map(); + const emissionPaths = /* @__PURE__ */ new Map(); + const denseIndices = /* @__PURE__ */ new Map(); + for (const node of context.dirty) { + recordDenseArrayPosition(node, denseIndices); + if (Array.isArray(nodeBase(node))) { + const overlay = arrayOverlay(node); + if (!overlay.structural && (overlay.baseOverrides?.size ?? 0) > 0) { + let candidates = denseIndices.get(node); + if (candidates === void 0) { + candidates = { indices: [], bits: void 0, length: nodeBase(node).length }; + denseIndices.set(node, candidates); + } + for (const index of overlay.baseOverrides.keys()) + addDenseCandidate(candidates, index); + } + } + } + const denseRegions = /* @__PURE__ */ new Map(); + for (const [array, candidates] of denseIndices) { + const path = resolvePath(array); + if (path === void 0 || path.some((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment))) { + continue; + } + const regions = buildDenseRegions(candidates); + if (regions.length > 0) + denseRegions.set(array, regions); + } + for (const node of context.dirty) { + const path = resolvePath(node); + if (path === void 0) + continue; + if (hasCoveringDenseRegion(node, path, denseRegions)) + continue; + emissionPaths.set(node, path); + if (!Array.isArray(nodeBase(node)) && hasReservedMutation(node)) + forcedFolds.set(node, path); + const reservedAt = path.findIndex((segment) => typeof segment === "string" && RESERVED_SEGMENTS.has(segment)); + if (reservedAt >= 0) { + let ancestor = node; + for (let depth = path.length; depth > reservedAt; depth--) + ancestor = nodeParent(ancestor); + forcedFolds.set(ancestor, path.slice(0, reservedAt)); + } + } + for (const [node, path] of forcedFolds) + emissionPaths.set(node, path); + for (const node of denseRegions.keys()) { + const path = resolvePath(node); + if (path !== void 0 && !hasCoveringDenseRegion(node, path, denseRegions)) + emissionPaths.set(node, path); + } + let maxDepth = 0; + for (const path of emissionPaths.values()) + maxDepth = Math.max(maxDepth, path.length); + const buckets = Array.from({ length: maxDepth + 1 }, () => []); + for (const [node, path] of emissionPaths) + buckets[path.length].push(node); + const folded = /* @__PURE__ */ new Set(); + for (const bucket of buckets) { + for (const node of bucket) { + const path = emissionPaths.get(node); + if (hasPlacementAncestor(node) || hasFoldedAncestor(node, folded)) + continue; + if (forcedFolds.has(node)) { + emitSet(operations, path, cloneNode(node)); + folded.add(node); + continue; + } + if (Array.isArray(nodeBase(node))) + emitArrayOperations(node, path, operations, denseRegions.get(node)); + else + emitObjectOperations(node, path, operations); + if (operations.length > MAX_DELTA_OPERATIONS) { + locatedDenseArray = void 0; + return [["r", cloneNode(context.root)]]; + } + } + } + locatedDenseArray = void 0; + return operations; +} +function emitSimpleObjectOperations(context) { + const nodes = []; + for (const node of context.dirty) { + if (Array.isArray(nodeBase(node)) || hasReservedMutation(node) || hasPlacementAncestor(node)) + return void 0; + for (let parent = nodeParent(node); parent !== void 0; parent = nodeParent(parent)) { + if (Array.isArray(nodeBase(parent))) + return void 0; + } + const path = resolvePath(node); + if (path === void 0) + continue; + for (const segment of path) { + if (typeof segment === "string" && RESERVED_SEGMENTS.has(segment)) + return void 0; + } + nodes.push(node); + if (nodes.length > MAX_SIMPLE_OBJECT_NODES) + return void 0; + } + for (let index = 1; index < nodes.length; index++) { + const node = nodes[index]; + const depth = node.preparedPath.length; + let at = index; + while (at > 0 && nodes[at - 1].preparedPath.length > depth) { + nodes[at] = nodes[at - 1]; + at -= 1; + } + nodes[at] = node; + } + const operations = []; + let canMaterializeDirectly = true; + for (const node of nodes) { + if (emitObjectOperations(node, node.preparedPath, operations)) + canMaterializeDirectly = false; + if (operations.length > MAX_DELTA_OPERATIONS) + return [["r", cloneNode(context.root)]]; + } + context.simpleObjectMaterialization = canMaterializeDirectly; + return operations; +} +var locatedDenseArray; +var locatedDenseIndex = 0; +function locateDenseArrayPosition(node) { + let child = node; + for (let parent = nodeParent(child); parent !== void 0; parent = nodeParent(child)) { + if (Array.isArray(nodeBase(parent))) { + const overlay = arrayOverlay(parent); + if (child.parentKind !== 1 || overlay.structural) + return false; + const sourceIndex = child.parentKey; + const valueIndex = overlay.baseOverrides?.get(sourceIndex); + const current = valueIndex === void 0 ? nodeBase(parent)[sourceIndex] : storedValue(parent.context, valueIndex); + if (current !== nodeBase(child)) + return false; + locatedDenseArray = parent; + locatedDenseIndex = sourceIndex; + return true; + } + child = parent; + } + return false; +} +function hasCoveringDenseRegion(node, path, denseRegions) { + for (let parent = nodeParent(node); parent !== void 0; parent = nodeParent(parent)) { + const regions = denseRegions.get(parent); + if (regions === void 0) + continue; + const parentPath = resolvePath(parent); + if (parentPath === void 0 || path.length <= parentPath.length) + continue; + let matches = true; + for (let index2 = 0; index2 < parentPath.length; index2++) { + if (path[index2] !== parentPath[index2]) { + matches = false; + break; + } + } + if (!matches) + continue; + const index = path[parentPath.length]; + if (typeof index === "number" && regionContaining(regions, index)) + return true; + } + return false; +} +function addDenseCandidate(candidates, index) { + if (candidates.bits !== void 0) { + candidates.bits[index] = 1; + return; + } + candidates.indices.push(index); + if (candidates.indices.length < 256) + return; + candidates.bits = new Uint8Array(candidates.length); + for (const existing of candidates.indices) + candidates.bits[existing] = 1; + candidates.indices.length = 0; +} +function recordDenseArrayPosition(node, groups) { + if (!locateDenseArrayPosition(node)) + return; + let candidates = groups.get(locatedDenseArray); + if (candidates === void 0) { + candidates = { indices: [], bits: void 0, length: nodeBase(locatedDenseArray).length }; + groups.set(locatedDenseArray, candidates); + } + addDenseCandidate(candidates, locatedDenseIndex); +} +function buildDenseRegions(candidates) { + if (candidates.bits === void 0) + return []; + const regions = []; + const bits = candidates.bits; + for (let at = 0; at < bits.length; ) { + while (at < bits.length && bits[at] === 0) + at += 1; + if (at === bits.length) + break; + const start = at; + let end = at; + let count = 0; + let gap = 0; + while (at < bits.length) { + if (bits[at] !== 0) { + count += 1; + end = at; + gap = 0; + } else if (++gap > 1) + break; + at += 1; + } + const length = end - start + 1; + if (count >= 256 && count * 2 >= length) + regions.push({ start, length }); + } + return regions; +} +function regionContaining(regions, index) { + for (const region of regions) + if (index >= region.start && index < region.start + region.length) + return true; + return false; +} +function hasReservedMutation(node) { + if (node.writeKey !== void 0 && RESERVED_SEGMENTS.has(node.writeKey)) + return true; + if (node.deleteKey !== void 0 && RESERVED_SEGMENTS.has(node.deleteKey)) + return true; + if (node.writes !== void 0) { + for (const key of node.writes.keys()) + if (RESERVED_SEGMENTS.has(key)) + return true; + } + if (node.deletes !== void 0) { + for (const key of node.deletes) + if (RESERVED_SEGMENTS.has(key)) + return true; + } + return false; +} +function hasFoldedAncestor(node, folded) { + for (let parent = nodeParent(node); parent !== void 0; parent = nodeParent(parent)) + if (folded.has(parent)) + return true; + return false; +} +function hasPlacementAncestor(node) { + for (let current = node; current !== void 0; current = nodeParent(current)) { + if (nodeParent(current) !== void 0 && current.parentPlacement) + return true; + } + return false; +} +function emitObjectWrite(node, path, operations, key, value2) { + const nextPath = [...path, key]; + const appended = node.appends?.get(key); + if (appended !== void 0) { + operations.push(["a", nextPath, appended]); + return false; + } + const before = node.readded?.has(key) || !Object.hasOwn(nodeBase(node), key) ? void 0 : nodeBase(node)[key]; + const after = cloneStored(value2, node.context); + const emitted = emitChangedValue(operations, nextPath, before, after); + return isContainer2(before) && isContainer2(after) && !emitted; +} +function emitObjectOperations(node, path, operations) { + if (node.readded !== void 0) { + for (const key of node.readded) { + if (Object.hasOwn(nodeBase(node), key)) + operations.push(["d", [...path, key]]); + } + } + let normalizedContainerWrite = false; + if (node.writeKey !== void 0) + normalizedContainerWrite = emitObjectWrite(node, path, operations, node.writeKey, objectValue(node, node.writeKey)); + if (node.writes !== void 0) { + for (const [key, valueIndex] of node.writes) { + if (operations.length > MAX_DELTA_OPERATIONS) + return normalizedContainerWrite; + if (emitObjectWrite(node, path, operations, key, storedValue(node.context, valueIndex))) + normalizedContainerWrite = true; + } + } + if (node.deleteKey !== void 0 && Object.hasOwn(nodeBase(node), node.deleteKey)) { + operations.push(["d", [...path, node.deleteKey]]); + } + if (node.deletes !== void 0) { + for (const key of node.deletes) { + if (operations.length > MAX_DELTA_OPERATIONS) + return normalizedContainerWrite; + if (Object.hasOwn(nodeBase(node), key)) + operations.push(["d", [...path, key]]); + } + } + return normalizedContainerWrite; +} +function buildArrayPlan(node) { + const overlay = arrayOverlay(node); + if (overlay.plan !== void 0) + return overlay.plan; + const base = nodeBase(node); + const pieces = piecesOf(overlay); + overlay.plan = orderedArrayPlan(base.length, pieces) ?? elementArrayPlan(base.length, pieces); + return overlay.plan; +} +function orderedArrayPlan(baseLength, pieces) { + const ranges = []; + for (const piece of pieces) { + if (piece.kind !== "base" || piece.length === 0) + continue; + if (piece.step !== 1 && piece.length > 1) + return void 0; + if (ranges.length > 0 && piece.start <= ranges[ranges.length - 1]) + return void 0; + ranges.push(piece.start, piece.start + piece.length - 1); + } + const removeRuns = []; + let end = baseLength; + for (let index = ranges.length - 2; index >= -2; index -= 2) { + const start = index >= 0 ? ranges[index + 1] + 1 : 0; + if (end > start) + removeRuns.push(start, end - start); + if (index >= 0) + end = ranges[index]; + } + return { removeRuns, permutation: void 0, insertRuns: insertRunsOf(pieces) }; +} +function elementArrayPlan(baseLength, pieces) { + const retained = new Uint8Array(baseLength); + const targetBase = []; + for (const piece of pieces) { + if (piece.kind !== "base") + continue; + for (let offset = 0; offset < piece.length; offset++) { + const index = piece.start + piece.step * offset; + retained[index] = 1; + targetBase.push(index); + } + } + const removeRuns = []; + for (let end = baseLength; end > 0; ) { + if (retained[end - 1] !== 0) { + end -= 1; + continue; + } + let start = end - 1; + while (start > 0 && retained[start - 1] === 0) + start -= 1; + removeRuns.push(start, end - start); + end = start; + } + const retainedBase = []; + for (let index = 0; index < retained.length; index++) + if (retained[index] !== 0) + retainedBase.push(index); + let permutation; + if (targetBase.some((value2, index) => value2 !== retainedBase[index])) { + const positions = new Map(retainedBase.map((value2, index) => [value2, index])); + permutation = targetBase.map((value2) => positions.get(value2)); + } + return { removeRuns, permutation, insertRuns: insertRunsOf(pieces) }; +} +function insertRunsOf(pieces) { + const insertRuns = []; + let logicalIndex = 0; + for (let pieceIndex = 0; pieceIndex < pieces.length; ) { + const piece = pieces[pieceIndex]; + if (piece.kind === "base") { + logicalIndex += piece.length; + pieceIndex += 1; + continue; + } + const startPiece = pieceIndex; + while (pieceIndex < pieces.length && pieces[pieceIndex].kind === "insert") { + logicalIndex += pieces[pieceIndex].length; + pieceIndex += 1; + } + insertRuns.push(logicalIndex - rangeLength(pieces, startPiece, pieceIndex), startPiece, pieceIndex); + } + return insertRuns; +} +function rangeLength(pieces, start, end) { + let length = 0; + for (let index = start; index < end; index++) + length += pieces[index].length; + return length; +} +function emitArrayOperations(node, path, operations, denseRegions) { + const overlay = arrayOverlay(node); + const base = nodeBase(node); + if (denseRegions !== void 0) { + for (const region of denseRegions) { + if (operations.length > MAX_DELTA_OPERATIONS) + return; + operations.push(["p", path, region.start, region.length, cloneArrayRegion(node, region)]); + } + } + if (overlay.structural) { + const plan = buildArrayPlan(node); + for (let index = 0; index < plan.removeRuns.length; index += 2) { + if (operations.length > MAX_DELTA_OPERATIONS) + return; + operations.push(["p", path, plan.removeRuns[index], plan.removeRuns[index + 1], []]); + } + if (plan.permutation !== void 0) + operations.push(["m", path, plan.permutation]); + const pieces = piecesOf(overlay); + for (let run2 = 0; run2 < plan.insertRuns.length; run2 += 3) { + if (operations.length > MAX_DELTA_OPERATIONS) + return; + const logicalIndex = plan.insertRuns[run2]; + const items = []; + for (let pieceIndex = plan.insertRuns[run2 + 1]; pieceIndex < plan.insertRuns[run2 + 2]; pieceIndex++) { + const piece = pieces[pieceIndex]; + for (let offset = 0; offset < piece.length; offset++) { + const sourceIndex = piece.start + piece.step * offset; + items.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + } + operations.push(["p", path, logicalIndex, 0, items]); + } + } + for (const [baseIndex, valueIndex] of overlay.baseOverrides ?? []) { + if (operations.length > MAX_DELTA_OPERATIONS) + return; + const index = findEntryIndex(overlay, 1, baseIndex, void 0); + if (index === void 0 || denseRegions !== void 0 && regionContaining(denseRegions, index)) + continue; + emitChangedValue(operations, [...path, index], base[baseIndex], cloneStored(storedValue(node.context, valueIndex), node.context)); + } +} +function cloneArrayRegion(node, region) { + const overlay = arrayOverlay(node); + const result = []; + for (let index = region.start; index < region.start + region.length; index++) { + const piece = locatePiece(overlay, index); + const sourceIndex = piece.start + piece.step * overlay.locatedOffset; + result.push(cloneStored(entryValueAt(node, piece, sourceIndex), node.context)); + } + return result; +} +function emitChangedValue(operations, path, before, after) { + if (!isContainer2(after) && before === after) + return false; + if (isContainer2(before) && isContainer2(after) && equalTrustedJson(before, after)) + return false; + if (typeof before === "string" && typeof after === "string") { + if (after.length > before.length && after.slice(0, before.length) === before) { + operations.push(["a", path, ownedSlice2(after, before.length)]); + return true; + } + const shared = overlap(before, after, 65536); + if (shared > 0) { + operations.push(["t", path, before.length - shared]); + if (after.length > shared) + operations.push(["a", path, ownedSlice2(after, shared)]); + return true; + } + } + operations.push(["s", path, after]); + return true; +} +function emitSet(operations, path, value2) { + if (path.length === 0) + operations.push(["r", value2]); + else + operations.push(["s", path, value2]); +} +function resolvePath(node) { + if (node.preparedPath !== void 0) + return node.preparedPath; + const parent = nodeParent(node); + if (parent === void 0) { + node.preparedPath = []; + return node.preparedPath; + } + const parentPath = resolvePath(parent); + if (parentPath === void 0) + return void 0; + if (node.parentKind === 0) { + const key = node.parentKey; + if (!objectHas(parent, key) || objectValue(parent, key) !== nodeBase(node)) + return void 0; + node.preparedPath = [...parentPath, key]; + return node.preparedPath; + } + const overlay = arrayOverlay(parent); + const index = findEntryIndex(overlay, node.parentKind, node.parentKey, node.parentSource); + if (index === void 0) + return void 0; + const piece = locatePiece(overlay, index); + if (entryValueAt(parent, piece, node.parentKey) !== nodeBase(node)) + return void 0; + node.preparedPath = [...parentPath, index]; + return node.preparedPath; +} +function ensurePieceLocations(overlay) { + if (overlay.baseLocations !== void 0) + return; + overlay.baseLocations = []; + overlay.insertLocations = /* @__PURE__ */ new Map(); + let logicalStart = 0; + for (const piece of piecesOf(overlay)) { + const last = piece.start + piece.step * (piece.length - 1); + const location = { + piece, + logicalStart, + minimum: Math.min(piece.start, last), + maximum: Math.max(piece.start, last) + }; + if (piece.kind === "base") + overlay.baseLocations.push(location); + else { + let locations = overlay.insertLocations.get(piece.source); + if (locations === void 0) { + locations = []; + overlay.insertLocations.set(piece.source, locations); + } + locations.push(location); + } + logicalStart += piece.length; + } + const byMinimum = (left, right) => left.minimum - right.minimum; + overlay.baseLocations.sort(byMinimum); + for (const locations of overlay.insertLocations.values()) + locations.sort(byMinimum); +} +function findEntryIndex(overlay, kind, sourceIndex, source) { + if (kind === 1 && !overlay.structural) + return sourceIndex; + ensurePieceLocations(overlay); + const locations = kind === 1 ? overlay.baseLocations : overlay.insertLocations.get(source); + if (locations === void 0) + return void 0; + let low = 0; + let high = locations.length; + while (low < high) { + const middle = low + high >>> 1; + if (locations[middle].minimum <= sourceIndex) + low = middle + 1; + else + high = middle; + } + const location = locations[low - 1]; + if (location === void 0 || sourceIndex > location.maximum) + return void 0; + const offset = (sourceIndex - location.piece.start) / location.piece.step; + return offset >= 0 && offset < location.piece.length ? location.logicalStart + offset : void 0; +} +function ensureOperations(context) { + if (context.ops !== void 0) + return context.ops; + assertReadable(context); + if (context.replacement) { + const base = context.tracker.deref()?.replacementBase(context); + context.replacementNoop = base !== void 0 && equalTrustedJson(base, nodeBase(context.root)); + context.ops = context.replacementNoop ? [] : [["r", nodeBase(context.root)]]; + } else + context.ops = emitOperations(context); + return context.ops; +} +function equalTrustedJson(left, right) { + if (left === right) + return true; + if (!isContainer2(left) || !isContainer2(right) || Array.isArray(left) !== Array.isArray(right)) + return false; + if (Array.isArray(left)) { + const other2 = right; + if (left.length !== other2.length) + return false; + for (let index = 0; index < left.length; index++) + if (!equalTrustedJson(left[index], other2[index])) + return false; + return true; + } + const other = right; + const keys = Object.keys(left); + if (keys.length !== Object.keys(other).length) + return false; + for (const key of keys) + if (!Object.hasOwn(other, key) || !equalTrustedJson(left[key], other[key])) + return false; + return true; +} +function abortContext(context) { + if (context.status.value === "aborted" || context.status.value === "consumed" || context.status.value === "stale") + return; + context.status.value = "aborted"; + clearContext(context); +} +function releaseOverlayReferences(context) { + context.tracker.deref()?.releaseContext(context); + if (locatedDenseArray?.context === context) + locatedDenseArray = void 0; + context.overlayReleased = true; + context.dirty.length = 0; + context.nodes.length = 0; + context.rawNodes = void 0; + context.ops = void 0; + context.root = void 0; + context.bases = void 0; + context.stored = void 0; + context.baseValue = void 0; +} +function clearContext(context) { + context.tracker.deref()?.releaseContext(context); + if (locatedDenseArray?.context === context) + locatedDenseArray = void 0; + for (const node of context.nodes) { + if (Array.isArray(node.target)) + node.target.length = 0; + Reflect.deleteProperty(node.target, NODE); + node.writes?.clear(); + node.deletes?.clear(); + node.readded?.clear(); + if (node.array?.pieces !== void 0) + node.array.pieces.length = 0; + if (node.array !== void 0) { + node.array.root = void 0; + node.array.baseLocations = void 0; + node.array.insertLocations = void 0; + } + node.array?.baseOverrides?.clear(); + node.array?.insertOverrides?.clear(); + node.parentSource = void 0; + node.target = RELEASED; + node.proxy = RELEASED; + node.writeKey = void 0; + node.writeValue = void 0; + node.writes = void 0; + node.deleteKey = void 0; + node.deletes = void 0; + node.readded = void 0; + node.array = void 0; + node.subtreeDirty = void 0; + node.preparedPath = void 0; + } + context.dirty.length = 0; + context.nodes.length = 0; + context.rawNodes = void 0; + context.ops = void 0; + context.root = void 0; + context.bases = void 0; + context.stored = void 0; + context.baseValue = void 0; + context.overlayReleased = true; +} +function isContainer2(value2) { + return value2 !== null && typeof value2 === "object"; +} + +// node_modules/@earendil-works/chord/dist/delta/pending.js +var MAX_PENDING_OPS = 1e4; +var EMPTY = { kind: "empty" }; +var REPLACE = { kind: "replace" }; +var NO_OPS = { kind: "ops", ops: [] }; +var REPLACE_TAKE = { kind: "replace" }; +var PendingOps = class { + #state = EMPTY; + /** Queue the ops of one commit. */ + push(ops) { + if (ops.length === 0) + return; + const state = this.#state; + if (ops[0][0] === "r") { + this.#state = { kind: "batch", ops }; + return; + } + switch (state.kind) { + case "empty": + this.#state = { kind: "batch", ops }; + return; + case "batch": { + const folding = { kind: "folding", ops: [], leaves: /* @__PURE__ */ new Map() }; + this.#state = folding; + if (this.#fold(state.ops, folding)) + this.#fold(ops, folding); + return; + } + case "folding": + this.#fold(ops, state); + return; + case "replace": + return; + } + } + /** + * Pending ops after folding; 1 once overflowed (the replacement the owner sends). One batch is counted as received, + * so a single commit larger than `MAX_PENDING_OPS` counts in full until a second one arrives and overflows. + */ + get size() { + const state = this.#state; + if (state.kind === "empty") + return 0; + return state.kind === "replace" ? 1 : state.ops.length; + } + /** Hand over everything pending and start empty. */ + take() { + const state = this.#state; + this.#state = EMPTY; + switch (state.kind) { + case "empty": + return NO_OPS; + case "batch": + case "folding": + return { kind: "ops", ops: state.ops }; + case "replace": + return REPLACE_TAKE; + } + } + /** Fold `ops` into `folding`; false once the queue overflowed into `replace`. */ + #fold(ops, folding) { + for (const op2 of ops) { + switch (op2[0]) { + case "r": + folding.ops.length = 0; + folding.leaves.clear(); + break; + case "a": + if (mergeLeaf(op2, folding)) + continue; + break; + case "s": + if (isPrimitive(op2[2])) { + if (mergeLeaf(op2, folding)) + continue; + } else if (folding.leaves.size > 0) + folding.leaves.clear(); + break; + default: + if (folding.leaves.size > 0) + folding.leaves.clear(); + } + folding.ops.push(op2); + if (folding.ops.length > MAX_PENDING_OPS) { + this.#state = REPLACE; + return false; + } + } + return true; + } +}; +var isPrimitive = (value2) => value2 === null || typeof value2 !== "object"; +function mergeLeaf(op2, folding) { + const key = pathKey(op2[1]); + const at = folding.leaves.get(key); + if (at !== void 0) { + const merged = merge2(folding.ops[at], op2); + if (merged !== void 0) { + folding.ops[at] = merged; + return true; + } + } + folding.leaves.set(key, folding.ops.length); + return false; +} +function pathKey(path) { + return JSON.stringify(path); +} +function merge2(previous, next) { + if (next[0] === "s") + return next; + if (previous[0] === "a") + return ["a", previous[1], previous[2] + next[2]]; + if (typeof previous[2] === "string") + return ["s", previous[1], previous[2] + next[2]]; + return void 0; +} + +// node_modules/@earendil-works/chord/dist/delta/index.js +var isObj = (value2) => value2 !== null && typeof value2 === "object"; +function overlap(a, b, scan, probe = 64, maxCandidates = 8) { + if (a.length === 0 || b.length === 0 || scan === 0) + return 0; + const tail = a.length > scan ? a.slice(a.length - scan) : a; + for (const h of [Math.min(probe, b.length), 1]) { + const head = b.slice(0, h); + let tried = 0; + for (let k = tail.indexOf(head); k !== -1; k = tail.indexOf(head, k + 1)) { + if (++tried > maxCandidates) + break; + const n = tail.length - k; + if (n <= b.length && tail.slice(k) === b.slice(0, n)) + return n; + } + if (h === 1) + break; + } + return 0; +} +function ownedSlice2(text, start, end = text.length) { + return `${text.slice(start, end)} `.slice(0, -1); +} +var RESERVED_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]); +var UnsafePathError = class extends Error { + // Not a parameter property: Node's --experimental-strip-types rejects those, + // and these files are meant to run under it directly. + segment; + constructor(segment) { + super(`unsafe path segment: ${String(segment)}`); + this.segment = segment; + this.name = "UnsafePathError"; + } +}; +function assertValidOp(op2) { + if (!Array.isArray(op2) || op2.length === 0) + throw new TypeError("op is not a tuple"); + switch (op2[0]) { + case "r": + if (op2.length !== 2) + throw new TypeError("r arity"); + return; + case "s": + if (op2.length !== 3) + throw new TypeError("s arity"); + assertPathArg(op2[1], true); + return; + case "d": + if (op2.length !== 2) + throw new TypeError("d arity"); + assertPathArg(op2[1], true); + return; + case "a": + if (op2.length !== 3 || typeof op2[2] !== "string") + throw new TypeError("a shape"); + assertPathArg(op2[1], true); + return; + case "t": + if (op2.length !== 3 || !Number.isInteger(op2[2]) || op2[2] < 0) + throw new TypeError("t shape"); + assertPathArg(op2[1], true); + return; + case "p": { + if (op2.length !== 5) + throw new TypeError("p arity"); + assertPathArg(op2[1]); + if (!Number.isInteger(op2[2]) || op2[2] < 0) + throw new TypeError("p index"); + if (!Number.isInteger(op2[3]) || op2[3] < 0) + throw new TypeError("p remove"); + if (!Array.isArray(op2[4])) + throw new TypeError("p items"); + return; + } + case "m": + if (op2.length !== 3) + throw new TypeError("m arity"); + assertPathArg(op2[1]); + assertPermutation(op2[2]); + return; + // Silently skipping an unknown verb is how a newer producer's op vanishes. + default: + throw new TypeError(`unknown op verb: ${String(op2[0])}`); + } +} +function assertPathArg(p, nonEmpty = false) { + if (!Array.isArray(p)) + throw new TypeError("path is not an array"); + if (nonEmpty && p.length === 0) + throw new TypeError("path is empty"); + assertSafePath(p); +} +function assertPermutation(value2) { + if (!Array.isArray(value2)) + throw new TypeError("m permutation is not an array"); + const seen = new Uint8Array(value2.length); + for (const index of value2) { + if (!Number.isInteger(index) || index < 0 || index >= value2.length || seen[index] !== 0) { + throw new TypeError("m permutation is not a bijection"); + } + seen[index] = 1; + } +} +function assertSafePath(path) { + for (const seg of path) { + if (typeof seg === "string") { + if (RESERVED_SEGMENTS.has(seg)) + throw new UnsafePathError(seg); + } else if (!Number.isInteger(seg) || seg < 0) { + throw new UnsafePathError(seg); + } + } +} +function assertIndexInRange(parent, index) { + if (index > parent.length) + throw new UnsafePathError(index); +} +var PathError = class extends Error { + path; + constructor(path) { + super(`unresolvable path: ${JSON.stringify(path)}`); + this.path = path; + this.name = "PathError"; + } +}; +function apply(target, ops) { + return applyOps(target, ops); +} +function applyOps(target, ops) { + let root2 = target; + for (const op2 of ops) { + assertValidOp(op2); + if (op2[0] === "r") { + root2 = op2[1]; + continue; + } + const path = op2[1]; + assertSafePath(path); + if (op2[0] === "p") { + const target_ = path.length === 0 ? root2 : resolve(root2, path); + if (!Array.isArray(target_)) + throw new PathError(path); + target_.splice(op2[2], op2[3]); + const chunkSize = 1e4; + for (let offset = 0; offset < op2[4].length; offset += chunkSize) { + target_.splice(op2[2] + offset, 0, ...op2[4].slice(offset, offset + chunkSize)); + } + continue; + } + if (op2[0] === "m") { + const target_ = path.length === 0 ? root2 : resolve(root2, path); + if (!Array.isArray(target_) || target_.length !== op2[2].length) + throw new PathError(path); + const previous = target_.slice(); + for (let index = 0; index < op2[2].length; index++) + target_[index] = previous[op2[2][index]]; + continue; + } + const parent = resolve(root2, path.slice(0, -1)); + const key = path[path.length - 1]; + if (Array.isArray(parent)) { + if (typeof key !== "number") + throw new UnsafePathError(key); + assertIndexInRange(parent, key); + } + const write = (value2) => { + Object.defineProperty(parent, key, { value: value2, writable: true, enumerable: true, configurable: true }); + }; + const read2 = () => Object.hasOwn(parent, key) ? parent[key] : void 0; + switch (op2[0]) { + case "s": + write(op2[2]); + break; + case "d": + if (Array.isArray(parent)) { + if (typeof key !== "number" || key >= parent.length) + throw new PathError(path); + parent.splice(key, 1); + } else + delete parent[key]; + break; + case "a": { + const current = read2(); + if (typeof current !== "string") + throw new PathError(path); + write(`${current}${op2[2]}`); + break; + } + case "t": { + const current = read2(); + if (typeof current !== "string") + throw new PathError(path); + write(current.slice(op2[2])); + break; + } + } + } + return root2; +} +function applyImmutable(target, ops) { + return applyImmutableBatches(target, [ops]); +} +function applyImmutableBatches(target, batches) { + let root2 = target; + const owned = /* @__PURE__ */ new WeakSet(); + for (const ops of batches) { + for (const op2 of ops) { + assertValidOp(op2); + if (op2[0] === "r") { + root2 = op2[1]; + continue; + } + if (op2[0] === "p") { + const appended = appendCopy(root2, op2, owned); + if (appended !== void 0) { + root2 = appended; + continue; + } + } + root2 = copyContainers(root2, op2[0] === "p" || op2[0] === "m" ? op2[1] : op2[1].slice(0, -1), owned); + root2 = applyOps(root2, [op2]); + } + } + return root2; +} +function appendCopy(root2, op2, owned) { + const [, path, index, deleteCount, items] = op2; + if (deleteCount !== 0) + return void 0; + let target = root2; + for (const segment of path) { + const valid = typeof segment === "string" ? !RESERVED_SEGMENTS.has(segment) && isObj(target) && !Array.isArray(target) : Number.isInteger(segment) && segment >= 0 && Array.isArray(target); + if (!valid || !Object.hasOwn(target, segment)) + return void 0; + target = target[segment]; + } + if (!Array.isArray(target) || owned.has(target) || index !== target.length) + return void 0; + const appended = target.concat(items); + owned.add(appended); + if (path.length === 0) + return appended; + const parentPath = path.slice(0, -1); + const copiedRoot = copyContainers(root2, parentPath, owned); + Object.defineProperty(resolve(copiedRoot, parentPath), path[path.length - 1], { + value: appended, + writable: true, + enumerable: true, + configurable: true + }); + return copiedRoot; +} +function copyContainers(root2, path, owned) { + const copy2 = (value2) => { + if (Array.isArray(value2)) + return value2.slice(); + if (!isObj(value2)) + throw new PathError(path); + return Object.getPrototypeOf(value2) === null ? Object.assign(/* @__PURE__ */ Object.create(null), value2) : { ...value2 }; + }; + if (!isObj(root2)) + throw new PathError(path); + let copiedRoot; + if (owned.has(root2)) + copiedRoot = root2; + else { + copiedRoot = copy2(root2); + owned.add(copiedRoot); + } + let destination = copiedRoot; + for (const segment of path) { + if (!Object.hasOwn(destination, segment)) + throw new PathError(path); + if (Array.isArray(destination) && typeof segment !== "number") + throw new UnsafePathError(segment); + const child = destination[segment]; + if (!isObj(child)) + throw new PathError(path); + if (owned.has(child)) { + destination = child; + continue; + } + const copiedChild = copy2(child); + Object.defineProperty(destination, segment, { + value: copiedChild, + writable: true, + enumerable: true, + configurable: true + }); + owned.add(copiedChild); + destination = copiedChild; + } + return copiedRoot; +} +function resolveValue(root2, path) { + let node = root2; + for (const seg of path) { + if (!isObj(node)) + throw new PathError(path); + if (Array.isArray(node) && typeof seg !== "number") + throw new UnsafePathError(seg); + if (!Object.hasOwn(node, seg)) + throw new PathError(path); + node = node[seg]; + } + return node; +} +function resolve(root2, path) { + const node = resolveValue(root2, path); + if (!isObj(node)) + throw new PathError(path); + return node; +} + +// node_modules/@earendil-works/chord/dist/watch.js +var STOPPED = { kind: "stopped" }; +var ABORTED = { kind: "aborted" }; +var ENDED = { kind: "ended" }; +var WatchLifecycle = class { + #detach; + #report; + #closed = Promise.withResolvers(); + #value; + #listener; + #started = false; + #scheduled = false; + #running = false; + /** Set by `endAfterDelivery()`: the source sends nothing more; the watch ends `ended` once it delivered the rest. */ + #ending = false; + /** Set once, by `#terminate()`. */ + #end; + #abortSignal; + #abortListener; + constructor(value2, detach, report2) { + this.#value = value2; + this.#detach = detach; + this.#report = report2; + } + get value() { + return this.#value; + } + get closed() { + return this.#closed.promise; + } + start(listener) { + if (this.#started) + throw new Error("Watch is already started"); + if (this.#end !== void 0) + throw new Error("Watch is stopped"); + this.#started = true; + this.#listener = listener; + this.#schedule(); + } + stop() { + this.#terminate(STOPPED); + return this.#closed.promise; + } + /** End the watch `aborted` when `signal` aborts; installed once. */ + observeAbort(signal) { + if (this.#abortSignal !== void 0) + throw new Error("Watch abort is already installed"); + if (this.#end !== void 0) + return; + this.#abortSignal = signal; + this.#abortListener = () => this.abort(); + signal.addEventListener("abort", this.#abortListener, { once: true }); + if (signal.aborted) + this.abort(); + } + /** End the watch `aborted`: its acquisition context was aborted. */ + abort() { + this.#terminate(ABORTED); + } + /** + * End the watch with `end` at once, dropping what is pending: the source closed (`ended`), or failed for good + * (`failed`). A failure the source may recover from is a `failed` call instead (`fail()`). + */ + end(end) { + this.#terminate(end); + } + /** + * The source sends nothing more after its last frames, such as a retirement: take no more frames, deliver what is + * pending, then end `ended`, after the running call returns, if one runs. Until then the watch stays attached, so + * `end()`, `stop()`, an abort, and a throwing listener still end it first; a watch never started keeps what is + * pending until one of them or `start()`. + */ + endAfterDelivery() { + if (this.#end !== void 0 || this.#ending) + return; + this.#ending = true; + if (!this.#running && this.pendingContext === void 0) + this.#terminate(ENDED); + } + /** Whether the watch still takes frames: it has not ended, and its source has not ended. */ + get open() { + return this.#end === void 0 && !this.#ending; + } + /** Something became pending: deliver it once started, never inline. */ + pending() { + if (this.#started) + this.#schedule(); + } + #schedule() { + if (this.#scheduled || this.#running || this.#end !== void 0) + return; + this.#scheduled = true; + queueMicrotask(() => { + this.#scheduled = false; + void this.#drain(); + }); + } + /** + * Deliver until nothing is pending; what arrives during a call is taken after it returns. A watch whose source ended + * ends once it delivered the rest and its last call returned. + */ + async #drain() { + if (this.#running || this.#end !== void 0 || !this.#started) + return; + this.#running = true; + try { + while (this.#end === void 0) { + const listener = this.#listener; + if (listener === void 0) + break; + const frameContext = this.pendingContext; + if (frameContext === void 0) { + if (this.#ending) + this.#terminate(ENDED); + break; + } + const call = this.next(); + if (call.kind === "value") + this.#value = call.value; + try { + await listener(call, withoutAbortSignal(frameContext)); + } catch (error) { + if (this.#end === void 0) { + this.#report?.(error); + this.#terminate({ kind: "listener_error", error: toError(error) }); + } + break; + } + } + } finally { + this.#running = false; + } + } + /** + * End the watch, once: detach, drop what is pending and every reference into the source, and settle `closed`. A + * callback already running keeps running. + */ + #terminate(end) { + if (this.#end !== void 0) + return; + this.#end = end; + const detach = this.#detach; + this.#detach = void 0; + this.#report = void 0; + this.#listener = void 0; + this.release(); + if (this.#abortListener !== void 0) + this.#abortSignal?.removeEventListener("abort", this.#abortListener); + this.#abortSignal = void 0; + this.#abortListener = void 0; + detach?.(); + this.#closed.resolve(end); + } +}; +function toError(error) { + if (error instanceof Error) + return error; + try { + return new Error(String(error)); + } catch { + return new Error(Object.prototype.toString.call(error)); + } +} +var Watch = class extends WatchLifecycle { + #ops = new PendingOps(); + /** + * The source's call while it is the only frame pending: the call passes it on, with no allocation. A second frame + * drops it, so its ops stay only as long as `PendingOps` keeps them (folded, or dropped by an overflow). + */ + #lone; + /** The newest frame's value and context, while a call is pending; the context also marks that one is. */ + #newestValue; + #newestContext; + /** A pending frame was a retirement. */ + #retired = false; + /** The newest frame was a failure: the next call is this. */ + #failure; + /** A failure came after the last value call: the next value call replaces the root, and ops are not kept. */ + #replace = false; + /** + * Queue the frame `call`. A value's ops turn the previous value into it, and `retired` says the document the watch + * follows was retired, which the next value call reports however many frames it folds; a failure is `fail()`. + */ + advance(call, context) { + if (call.kind === "failed") { + this.#fail(call, context); + return; + } + if (!this.open) + return; + if (this.#replace) { + this.#failure = void 0; + } else { + this.#lone = this.#newestContext === void 0 ? call : void 0; + this.#ops.push(call.ops); + } + this.#newestValue = call.value; + this.#newestContext = context; + if (call.retired) + this.#retired = true; + this.pending(); + } + /** + * Queue a failure the source may recover from: the pending frames are dropped (`value` keeps the last delivered + * value), the listener gets `{ kind: "failed", error }`, and the next value arrives as `[["r", value]]`. + */ + fail(error, context) { + this.#fail({ kind: "failed", error }, context); + } + #fail(call, context) { + if (!this.open) + return; + this.#ops.take(); + this.#lone = void 0; + this.#newestValue = void 0; + this.#failure = call; + this.#newestContext = context; + this.#replace = true; + this.pending(); + } + get pendingContext() { + return this.#newestContext; + } + next() { + if (this.#replace) + return this.#nextAfterFailure(); + const lone = this.#lone; + const value2 = this.#newestValue; + const batch = this.#ops.take(); + const retired = this.#retired; + this.#lone = void 0; + this.#newestValue = void 0; + this.#newestContext = void 0; + this.#retired = false; + if (lone !== void 0) + return lone; + const ops = batch.kind === "ops" ? batch.ops : [["r", value2]]; + return { kind: "value", value: value2, ops, retired }; + } + /** The pending failure, or the value after one as a root replacement (no ops were kept since the failure). */ + #nextAfterFailure() { + const failure = this.#failure; + this.#newestContext = void 0; + if (failure !== void 0) { + this.#failure = void 0; + return failure; + } + const value2 = this.#newestValue; + const retired = this.#retired; + this.#newestValue = void 0; + this.#retired = false; + this.#replace = false; + return { kind: "value", value: value2, ops: [["r", value2]], retired }; + } + release() { + this.#ops.take(); + this.#lone = void 0; + this.#newestValue = void 0; + this.#newestContext = void 0; + this.#retired = false; + this.#failure = void 0; + this.#replace = false; + } +}; + +// node_modules/@earendil-works/pi-durable/dist/ids.js +function idFromNumber(value2) { + return value2; +} +function seqFromNumber(value2) { + return value2; +} + +// node_modules/@earendil-works/pi-durable/dist/documents.js +function defineDoc(definition) { + validateDefinition(definition); + return { definition }; +} +function defineDocFamily(definition) { + validateDefinition(definition); + return { definition }; +} +function validateDefinition(definition) { + if (!Number.isSafeInteger(definition.version) || definition.version < 1) { + throw new TypeError(`Document ${definition.kind} version must be a positive integer`); + } +} +function resolveAddress(definition, args) { + let index = 0; + let scope; + switch (definition.scope) { + case "session": + scope = { kind: "session" }; + break; + case "conversation": + scope = { kind: "conversation", conversationId: ownerId(args[index++], definition) }; + break; + case "task": + scope = { kind: "task", taskId: ownerId(args[index++], definition) }; + break; + } + const key = definition.family === true ? args[index++] : void 0; + const address = key === void 0 ? { kind: definition.kind, scope } : { kind: definition.kind, scope, key }; + return { address, id: addressId(address), nextArgument: index }; +} +function ownerId(value2, definition) { + if (typeof value2 !== "number" || !Number.isSafeInteger(value2)) { + throw new TypeError(`Document ${definition.kind} requires a ${definition.scope} ID`); + } + return idFromNumber(value2); +} +function addressId(address) { + const owner = address.scope.kind === "session" ? null : address.scope.kind === "conversation" ? address.scope.conversationId : address.scope.taskId; + return JSON.stringify([address.kind, address.scope.kind, owner, address.key ?? null]); +} +function documentCreate(definition, address, id) { + const key = address.key === void 0 ? {} : { key: address.key }; + switch (address.scope.kind) { + case "session": + return { id, kind: address.kind, ...key, scope: address.scope }; + case "task": + return { id, kind: address.kind, ...key, scope: address.scope }; + case "conversation": + return { + id, + kind: address.kind, + ...key, + scope: address.scope, + history: definition.history, + fork: definition.fork + }; + } +} +function checkRecordScope(definition, record) { + if (record.scope.kind !== definition.scope || record.scope.kind === "conversation" && (record.history !== definition.history || record.fork !== definition.fork)) { + throw new TypeError(`Document ${record.id} (${record.kind}) does not match the supplied definition semantics`); + } +} +function checkRecordVersion(definition, record, version) { + if (version > definition.version) { + throw new Error(`Document ${record.id} (${record.kind}) has newer version ${version} than ${definition.version}`); + } + if (version < definition.version && definition.migrate === void 0) { + throw new Error(`Document ${record.id} (${record.kind}) requires migration from version ${version}`); + } +} +function materializeDocument(definition, stored) { + return materializeDocumentValue(definition, stored.record, stored.version, stored.value); +} +function materializeDocumentValue(definition, record, version, value2) { + checkRecordScope(definition, record); + checkRecordVersion(definition, record, version); + if (version === definition.version) + return value2; + return copyJson(definition.migrate(value2, version)); +} + +// node_modules/@earendil-works/pi-durable/dist/entries.js +function defineEntry(kind) { + if (typeof kind !== "string" || kind.length === 0) + throw new TypeError("Entry kind must be a non-empty string"); + return { + kind, + is: (entry2) => entry2 !== void 0 && entry2.kind === kind + }; +} +var UserEntry = defineEntry("pi.user"); +var AssistantEntry = defineEntry("pi.assistant"); +var SystemEntry = defineEntry("pi.system"); +var ToolResultEntry = defineEntry("pi.tool-result"); +var ResetEntry = defineEntry("pi.reset"); +var CompactionEntry = defineEntry("pi.compaction"); + +// node_modules/@earendil-works/pi-durable/dist/errors.js +function errorMessage(error) { + if (error instanceof Error) + return error.message; + try { + return String(error); + } catch { + return Object.prototype.toString.call(error); + } +} +var ReadAfterWrite = class extends Error { + /** `operation` names the call, such as `Tx.task()`. */ + constructor(operation) { + super(`${operation} cannot read tables after the first table write`); + this.name = "ReadAfterWrite"; + } +}; +var StorageRequestError = class extends Error { + constructor(message) { + super(message); + this.name = "StorageRequestError"; + } +}; +var SessionFailed = class extends Error { + constructor(cause) { + super("Session failed after a storage error; close and reopen it", { cause }); + this.name = "SessionFailed"; + } +}; +var ReadOnlySession = class extends Error { + constructor(what) { + super(`Read-only Harness: ${what} refused`); + this.name = "ReadOnlySession"; + } +}; +var ConversationBusy = class extends Error { + conversationId; + constructor(conversationId) { + super(`Conversation ${conversationId} is busy`); + this.name = "ConversationBusy"; + this.conversationId = conversationId; + } +}; +var ConversationIdle = class extends Error { + conversationId; + constructor(conversationId) { + super(`Conversation ${conversationId} is idle`); + this.name = "ConversationIdle"; + this.conversationId = conversationId; + } +}; + +// node_modules/@earendil-works/pi-durable/dist/timers.js +var MAX_TIMER_DELAY = 2147483647; +var inWorkers = typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers"; +var nativeTimer = (callback, delayMs, options) => { + if (options.background && inWorkers) + return void 0; + const arm = (remaining) => { + const next = setTimeout(() => { + if (remaining > MAX_TIMER_DELAY) + handle = arm(remaining - MAX_TIMER_DELAY); + else + callback(); + }, Math.min(remaining, MAX_TIMER_DELAY)); + if (options.background) + next.unref?.(); + return next; + }; + let handle = arm(Math.max(0, delayMs)); + if (options.background && typeof handle.unref !== "function") { + clearTimeout(handle); + return void 0; + } + return () => clearTimeout(handle); +}; +function hostTimers(setTimer, report2) { + const arm = setTimer ?? nativeTimer; + let reported = false; + const fallBack = (error) => { + if (reported) + return; + reported = true; + report2(error); + }; + return { + foreground: (callback, delayMs) => { + try { + const disarm = arm(callback, delayMs, { background: false }); + if (disarm !== void 0) + return disarm; + fallBack(new Error("HarnessOptions.setTimer declined a timer that paces work; using setTimeout")); + } catch (error) { + fallBack(error); + } + return nativeTimer(callback, delayMs, { background: false }); + }, + background: (callback, delayMs) => { + try { + return arm(callback, delayMs, { background: true }); + } catch (error) { + report2(error); + return void 0; + } + } + }; +} + +// node_modules/@earendil-works/pi-durable/dist/session/cached-storage.js +var CachedStorage = class { + #storage; + #conversations = new Lru(4096); + /** Live (non-terminal) task records, until their task ends. */ + #liveTasks = /* @__PURE__ */ new Map(); + /** + * Tasks some task waits on, or waited on until its wait ended and it has not moved on since: how many such waits name + * each, and its ended record once it ended. + */ + #awaited = /* @__PURE__ */ new Map(); + /** Per task that waits, or left its wait and has not changed since: what it waited on, and whether it left. */ + #waits = /* @__PURE__ */ new Map(); + // Ended task records carry inputs and outcomes, such as a tool call's arguments, so fewer are kept. + #tasks = new Lru(256); + #submissions = new Lru(256); + /** Per record with a read in flight: the reads, and how many published commits wrote it since the first began. */ + #reads = /* @__PURE__ */ new Map(); + constructor(storage) { + this.#storage = storage; + } + /** The backend's own scope for a publication (`Storage.publishing`), when it has one. */ + get publishing() { + return scopeOf(this.#storage); + } + /** Pass the batches through; the Session applies each one with `published()` when it publishes it. */ + commit(batches, receive, context) { + return this.#storage.commit(batches, receive, context); + } + /** + * Apply a batch `commit()` stored, when the Session publishes it. Its records are frozen in place and kept as + * they are, no copies: the Session's components that follow the publication, such as the scheduler's live tasks, + * hold the same objects, so a live task's record is in memory once. + */ + published(writes) { + for (const write of writes) { + switch (write.type) { + case "conversation": + this.#wrote(write.value.id); + this.#conversations.set(write.value.id, freeze(write.value)); + break; + case "task": + this.#wrote(write.value.id); + this.#followWait(write.value); + this.#keepTask(freeze(write.value)); + break; + case "submission": + this.#wrote(write.value.id); + this.#submissions.set(write.value.id, freeze(write.value)); + break; + } + } + } + mintId() { + return this.#storage.mintId(); + } + conversation(id, context) { + const cached = this.#conversations.get(id); + if (cached !== void 0) + return Promise.resolve(cached); + return this.#keep(id, () => this.#storage.conversation(id, context), (record) => this.#conversations.set(id, record)); + } + scanConversations(query, limit, cursor, context) { + return this.#storage.scanConversations(query, limit, cursor, context); + } + entry(first, second, third) { + if (third === void 0) + return this.#storage.entry(first, second); + return this.#storage.entry(first, second, third); + } + findLatestHeadMarker(conversationId, atOrBeforeEntryId, context) { + return this.#storage.findLatestHeadMarker(conversationId, atOrBeforeEntryId, context); + } + scanEntries(query, limit, cursor, context) { + return this.#storage.scanEntries(query, limit, cursor, context); + } + task(id, context) { + const cached = this.#liveTasks.get(id) ?? this.#awaited.get(id)?.record ?? this.#tasks.get(id); + if (cached !== void 0) + return Promise.resolve(cached); + return this.#keep(id, () => this.#storage.task(id, context), (record) => this.#keepTask(record)); + } + scanTasks(query, limit, cursor, context) { + return this.#storage.scanTasks(query, limit, cursor, context); + } + /** Cached as written only: this process writes every submission it settles, and a read one is usually settled. */ + submission(id, context) { + const cached = this.#submissions.get(id); + return cached === void 0 ? this.#storage.submission(id, context) : Promise.resolve(cached); + } + scanSubmissions(query, limit, cursor, context) { + return this.#storage.scanSubmissions(query, limit, cursor, context); + } + submissionByRequest(conversationId, requestId, context) { + return this.#storage.submissionByRequest(conversationId, requestId, context); + } + findDocument(address, at, context) { + return this.#storage.findDocument(address, at, context); + } + document(id, at, context) { + return this.#storage.document(id, at, context); + } + scanDocuments(query, limit, cursor, context) { + return this.#storage.scanDocuments(query, limit, cursor, context); + } + close(context) { + return this.#storage.close(context); + } + /** Records kept per table, for tests that bound memory. */ + get sizes() { + return { + conversations: this.#conversations.size, + tasks: this.#tasks.size, + liveTasks: this.#liveTasks.size, + awaited: this.#awaited.size, + waits: this.#waits.size, + submissions: this.#submissions.size + }; + } + /** Keep a frozen task record: a live one until its task ends, an ended one in the bounded table. */ + #keepTask(record) { + if (record.state.status === "terminal") { + this.#liveTasks.delete(record.id); + const awaited = this.#awaited.get(record.id); + if (awaited !== void 0) + awaited.record = record; + else + this.#tasks.set(record.id, record); + return; + } + this.#tasks.delete(record.id); + this.#liveTasks.set(record.id, record); + } + /** + * Follow a task's waits from its written record: a wait holds the ended records of its `on` until the task, after + * leaving the wait, changes again (or ends), as the phase that resumes reads their outcomes before its next commit. + */ + #followWait(record) { + const wait = this.#waits.get(record.id); + if (record.state.status === "waiting") { + if (wait !== void 0) + this.#release(wait.on); + const on = record.state.on; + this.#waits.set(record.id, { on, left: false }); + for (const id of on) { + let awaited = this.#awaited.get(id); + if (awaited === void 0) { + const ended = this.#tasks.get(id); + this.#tasks.delete(id); + awaited = ended === void 0 ? { count: 0 } : { count: 0, record: ended }; + this.#awaited.set(id, awaited); + } + awaited.count++; + } + return; + } + if (wait === void 0) + return; + if (!wait.left && record.state.status !== "terminal") { + wait.left = true; + return; + } + this.#waits.delete(record.id); + this.#release(wait.on); + } + /** A wait on `on` is over: what no other wait names goes back to the bounded table. */ + #release(on) { + for (const id of on) { + const awaited = this.#awaited.get(id); + if (--awaited.count > 0) + continue; + this.#awaited.delete(id); + if (awaited.record !== void 0) + this.#tasks.set(id, awaited.record); + } + } + /** A published commit wrote record `id`: a read of it in flight may have missed that. */ + #wrote(id) { + const reads = this.#reads.get(id); + if (reads !== void 0) + reads.version++; + } + /** Resolve with a read's result, frozen; keep a found record unless a commit published since wrote it. */ + async #keep(id, read2, keep) { + let reads = this.#reads.get(id); + if (reads === void 0) { + reads = { count: 0, version: 0 }; + this.#reads.set(id, reads); + } + reads.count++; + const version = reads.version; + let value2; + try { + value2 = await read2(); + } finally { + if (--reads.count === 0) + this.#reads.delete(id); + } + if (value2 === void 0) + return void 0; + freeze(value2); + if (reads.version === version) + keep(value2); + return value2; + } + /** Records with a read in flight, for tests that bound memory. */ + get readsInFlight() { + return this.#reads.size; + } +}; +function scopeOf(storage) { + if (storage.publishing === void 0) + return void 0; + return (publish2, context) => storage.publishing(publish2, context); +} +var Lru = class { + #map = /* @__PURE__ */ new Map(); + #capacity; + constructor(capacity) { + this.#capacity = capacity; + } + get(key) { + const value2 = this.#map.get(key); + if (value2 === void 0) + return void 0; + this.#map.delete(key); + this.#map.set(key, value2); + return value2; + } + get size() { + return this.#map.size; + } + delete(key) { + this.#map.delete(key); + } + set(key, value2) { + this.#map.delete(key); + this.#map.set(key, value2); + if (this.#map.size > this.#capacity) + this.#map.delete(this.#map.keys().next().value); + } +}; +function freeze(value2) { + if (value2 === null || typeof value2 !== "object" || Object.isFrozen(value2)) + return value2; + for (const child of Object.values(value2)) + freeze(child); + return Object.freeze(value2); +} + +// node_modules/@earendil-works/pi-durable/dist/session/conversation-tails.js +var LATEST_CAPACITY = 4096; +var MARKER_CAPACITY = 256; +var ConversationTails = class { + /** Newest entry ID per conversation, `null` for none. */ + #latest = /* @__PURE__ */ new Map(); + /** Newest head marker per conversation, `null` for none. */ + #markers = /* @__PURE__ */ new Map(); + /** The newest entry ID visible from `conversationId`, read with `read` when not known. */ + async latest(conversationId, read2) { + const known = use(this.#latest, conversationId); + if (known !== void 0) + return known ?? void 0; + const latest = await read2(); + put(this.#latest, conversationId, latest ?? null, LATEST_CAPACITY); + return latest; + } + /** + * The newest visible head marker at or before `atOrBefore` (any when `undefined`), read with `read` when not known. + * The newest marker answers every cutoff at or after it, and no marker answers every cutoff. + */ + async marker(conversationId, atOrBefore, read2) { + const marker = use(this.#markers, conversationId); + if (marker === null) + return void 0; + if (marker !== void 0 && (atOrBefore === void 0 || atOrBefore >= marker.id)) + return marker; + const latest = this.#latest.get(conversationId); + const found = await read2(); + if (atOrBefore === void 0 || latest !== void 0 && (latest ?? 0) <= atOrBefore) { + put(this.#markers, conversationId, found === void 0 ? null : freeze2(found), MARKER_CAPACITY); + } + return found; + } + /** The newest entry ID of `conversationId` if known, without reading: `null` for none, `undefined` when unknown. */ + peek(conversationId) { + return this.#latest.get(conversationId); + } + /** At a commit's publication: apply a durable batch. */ + apply(writes) { + for (const write of writes) { + if (write.type === "conversation") { + if (write.value.parent !== void 0) + continue; + put(this.#latest, write.value.id, null, LATEST_CAPACITY); + put(this.#markers, write.value.id, null, MARKER_CAPACITY); + } else if (write.type === "entry") { + const entry2 = write.value; + put(this.#latest, entry2.conversationId, entry2.id, LATEST_CAPACITY); + if (entry2.head !== void 0) { + put(this.#markers, entry2.conversationId, freeze2(entry2), MARKER_CAPACITY); + } + } + } + } + /** How many conversations have their newest entry kept, for tests that bound memory. */ + get size() { + return this.#latest.size; + } + /** How many conversations have their newest head marker kept, for tests that bound memory. */ + get markers() { + return this.#markers.size; + } +}; +function use(map, key) { + const value2 = map.get(key); + if (value2 === void 0) + return void 0; + map.delete(key); + map.set(key, value2); + return value2; +} +function put(map, key, value2, capacity) { + map.delete(key); + map.set(key, value2); + if (map.size > capacity) + map.delete(map.keys().next().value); +} +function freeze2(value2) { + if (value2 === null || typeof value2 !== "object" || Object.isFrozen(value2)) + return value2; + for (const child of Object.values(value2)) + freeze2(child); + return Object.freeze(value2); +} + +// node_modules/@earendil-works/pi-durable/dist/session/keys.js +function taskKey(id) { + return `task:${id}`; +} +function conversationKey(id) { + return `conv:${id}`; +} +function entryKey(id) { + return `entry:${id}`; +} +function appendKey(id) { + return `entry-append:${id}`; +} +function submissionKey(id) { + return `sub:${id}`; +} +function requestKey(conversationId, requestId) { + return `req:${conversationId}:${requestId}`; +} +function documentKey(addressId2) { + return `doc:${addressId2}`; +} +function incarnationKey(id) { + return `docid:${id}`; +} +function scopeKey(scope) { + switch (scope.kind) { + case "session": + return "docscope:session"; + case "conversation": + return `docscope:c${scope.conversationId}`; + case "task": + return `docscope:t${scope.taskId}`; + } +} +function ownedKey(node) { + return `owned:${node}`; +} +var ROOTS_KEY = "roots"; +var INTENT_KEY = "intent"; +var ALL_KEYS = "*"; +function tableKeys(writes, keys) { + for (const write of writes) { + switch (write.type) { + case "conversation": + keys.push(conversationKey(write.value.id)); + break; + case "entry": + keys.push(entryKey(write.value.id), appendKey(write.value.conversationId)); + break; + case "task": + keys.push(taskKey(write.value.id)); + break; + case "submission": + keys.push(submissionKey(write.value.id)); + if (write.value.requestId !== void 0) { + keys.push(requestKey(write.value.conversationId, write.value.requestId)); + } + break; + } + } +} +var GatedStorage = class { + #storage; + #touch; + constructor(storage, touch) { + this.#storage = storage; + this.#touch = touch; + } + commit(batches, receive, context) { + return this.#storage.commit(batches, receive, context); + } + mintId() { + return this.#storage.mintId(); + } + conversation(id, context) { + return this.#after(conversationKey(id), () => this.#storage.conversation(id, context)); + } + scanConversations(query, limit, cursor, context) { + return this.#after(ALL_KEYS, () => this.#storage.scanConversations(query, limit, cursor, context)); + } + entry(first, second, third) { + if (third === void 0) { + const id2 = first; + return this.#after(entryKey(id2), () => this.#storage.entry(id2, second)); + } + const conversationId = first; + const id = second; + return this.#after(conversationKey(conversationId), () => this.#after(entryKey(id), () => this.#storage.entry(conversationId, id, third))); + } + findLatestHeadMarker(conversationId, atOrBeforeEntryId, context) { + return this.#afterEntries(conversationId, () => this.#storage.findLatestHeadMarker(conversationId, atOrBeforeEntryId, context)); + } + scanEntries(query, limit, cursor, context) { + return this.#afterEntries(query.conversationId, () => this.#storage.scanEntries(query, limit, cursor, context)); + } + task(id, context) { + return this.#after(taskKey(id), () => this.#storage.task(id, context)); + } + scanTasks(query, limit, cursor, context) { + return this.#after(ALL_KEYS, () => this.#storage.scanTasks(query, limit, cursor, context)); + } + submission(id, context) { + return this.#after(submissionKey(id), () => this.#storage.submission(id, context)); + } + scanSubmissions(query, limit, cursor, context) { + return this.#after(ALL_KEYS, () => this.#storage.scanSubmissions(query, limit, cursor, context)); + } + submissionByRequest(conversationId, requestId, context) { + return this.#after(requestKey(conversationId, requestId), () => this.#storage.submissionByRequest(conversationId, requestId, context)); + } + findDocument(address, at, context) { + return this.#after(documentKey(addressId(address)), () => this.#storage.findDocument(address, at, context)); + } + document(id, at, context) { + return this.#after(incarnationKey(id), () => this.#storage.document(id, at, context)); + } + scanDocuments(query, limit, cursor, context) { + return this.#after(scopeKey(query.scope), () => this.#storage.scanDocuments(query, limit, cursor, context)); + } + close(context) { + return this.#storage.close(context); + } + /** + * Run a read of a conversation's entries once neither its appends nor its creation has an unpublished writer: Storage + * refuses the read of a conversation it does not have yet. + */ + #afterEntries(conversationId, read2) { + return this.#after(conversationKey(conversationId), () => this.#after(appendKey(conversationId), read2)); + } + /** Run `read` once `key` has no unpublished writer. */ + #after(key, read2) { + const gate = this.#touch(key); + return gate === void 0 ? read2() : gate.then(read2); + } +}; + +// node_modules/@earendil-works/pi-durable/dist/types.js +var ROOT_CONVERSATION_ID = 1; + +// node_modules/@earendil-works/pi-durable/dist/session/forks.js +var SCAN_PAGE_SIZE = 256; +async function prepareForkDocumentCopies(storage, parentConversationId, at, childConversationId, context) { + const entry2 = await storage.entry(parentConversationId, at, context); + if (entry2 === void 0) + throw new Error(`Entry ${at} is not visible from conversation ${parentConversationId}`); + const copies = []; + const copiedAddresses = /* @__PURE__ */ new Set(); + await collectCopies(storage, { kind: "conversation", conversationId: entry2.entry.conversationId }, entry2.commitSeq, "asOf", childConversationId, copies, copiedAddresses, context); + await collectCopies(storage, { kind: "conversation", conversationId: parentConversationId }, "current", "current", childConversationId, copies, copiedAddresses, context); + return copies; +} +async function collectCopies(storage, scope, at, policy, childConversationId, copies, copiedAddresses, context) { + let cursor; + do { + const page2 = await storage.scanDocuments({ scope, at }, SCAN_PAGE_SIZE, cursor, context); + for (const source of page2.items) { + if (source.scope.kind !== "conversation" || source.fork !== policy) + continue; + const id = await storage.mintId(); + const identity = { + id, + kind: source.kind, + ...source.key === void 0 ? {} : { key: source.key }, + scope: { kind: "conversation", conversationId: childConversationId } + }; + const record = source.history === "latest" ? { ...identity, history: "latest", fork: source.fork } : { ...identity, history: "rewindable", fork: source.fork }; + const copyAddress = addressId(record); + if (copiedAddresses.has(copyAddress)) { + const member = record.key === void 0 ? record.kind : `${record.kind}/${record.key}`; + throw new Error(`Fork selects multiple source documents for ${member}`); + } + copiedAddresses.add(copyAddress); + copies.push({ record, source: { id: source.id, at } }); + } + cursor = page2.next; + } while (cursor !== void 0); +} + +// node_modules/@earendil-works/pi-durable/dist/session/transaction.js +function applySubmissionChange(current, change) { + if (current.status === "done" || current.status === "unanswered") + return current; + if (change.status === "placed") { + if (current.status !== "queued") + throw new Error(`Submission ${current.id} is not queued`); + const status = current.type === "input" ? "placed" : "done"; + return { ...current, status, entry: change.entry }; + } + if (change.status === "done" && current.status !== "placed") { + throw new Error(`Submission ${current.id} is not a placed input or continue`); + } + return { ...current, ...change }; +} +var INTERNAL_SCAN_PAGE_SIZE = 256; +var EMPTY_OPERATIONS = []; +var TABLE_JSON_COPY_OPTIONS = { omitUndefinedProperties: true }; +var Transaction = class { + #host; + #context; + #scope; + #pendingOperations = /* @__PURE__ */ new Set(); + #sealed = false; + #hasTableWrite = false; + /** Atomic batch; conversation and entry writes stage eagerly, while task and document writes assemble later. */ + #writes = []; + #createdConversationIds = /* @__PURE__ */ new Set(); + #forkSourceConversationIds = /* @__PURE__ */ new Set(); + #forkSourceDocumentIds = /* @__PURE__ */ new Set(); + /** One entry per task touched by a public read, candidate write, or document-owner lookup. */ + #tasksById = /* @__PURE__ */ new Map(); + /** Submissions created by this transaction, by ID. */ + #submissions = /* @__PURE__ */ new Map(); + /** Submission settlements and placements in staging order; resolved against the latest candidate record during assembly. */ + #submissionChanges = []; + /** Write and publication plans of every staged incarnation, built during assembly. */ + #plans = []; + /** Every document acquisition or retirement marker in staging order. */ + #documents = []; + /** Latest transaction-local incarnation or retirement marker at each logical address. */ + #latestDocumentByAddress = /* @__PURE__ */ new Map(); + constructor(host, context, scope = {}) { + this.#host = host; + this.#context = context; + this.#scope = scope; + } + /** Internal: the context of the commit this transaction belongs to. */ + get context() { + return this.#context; + } + /** Internal: whether a table write was staged, after which table reads throw `ReadAfterWrite`. */ + get hasTableWrite() { + return this.#hasTableWrite; + } + /** + * Internal: whether `tx` is a transaction of a commit of the Session behind `host`. A brand check, so a proxy or + * another wrapper of a transaction is not one. + */ + static ownedBy(tx, host) { + return #host in tx && tx.#host === host; + } + // ─── Table reads ──────────────────────────────────────────────────────── + conversation(id) { + return this.#read("conversation", () => this.#host.storage.conversation(id, this.#context)); + } + entry(first, second) { + const kind = typeof first === "number" ? void 0 : first.kind; + const id = typeof first === "number" ? first : second; + return this.#read("entry", async () => { + const entry2 = (await this.#host.storage.entry(id, this.#context))?.entry; + return kind === void 0 || entry2?.kind === kind ? entry2 : void 0; + }); + } + task(id) { + return this.#read("task", () => this.#committedTask(id)); + } + scanConversations(query, limit, cursor) { + return this.#read("scanConversations", () => this.#host.storage.scanConversations(query, limit, cursor, this.#context)); + } + scanEntries(query, limit, cursor) { + return this.#read("scanEntries", () => this.#host.storage.scanEntries(query, limit, cursor, this.#context)); + } + latestHeadMarker(conversationId) { + return this.#read("latestHeadMarker", () => this.#host.latestHeadMarker(conversationId, this.#context)); + } + latestEntryId(conversationId) { + return this.#read("latestEntryId", () => this.#host.latestEntryId(conversationId, this.#context)); + } + scanTasks(query, limit, cursor) { + return this.#read("scanTasks", () => this.#host.storage.scanTasks(query, limit, cursor, this.#context)); + } + /** Internal: committed submission record. */ + submission(id) { + return this.#read("submission", () => this.#host.storage.submission(id, this.#context)); + } + /** Committed submission with a conversation-scoped request ID. */ + submissionByRequest(conversationId, requestId) { + return this.#read("submissionByRequest", () => this.#host.storage.submissionByRequest(conversationId, requestId, this.#context)); + } + // ─── Table writes ─────────────────────────────────────────────────────── + createConversation(options) { + return this.#write(() => this.#stageConversation(void 0, options.ownership)); + } + /** Internal final-form bootstrap path for the reserved root identity. */ + createRootConversation() { + return this.#write(() => this.#stageConversation(void 0, { kind: "ownerless" }, ROOT_CONVERSATION_ID)); + } + forkConversation(parentConversationId, at, options) { + return this.#write(() => this.#stageConversation({ conversationId: parentConversationId, at }, options.ownership)); + } + async #stageConversation(parent, ownership, reservedId) { + const ownerTaskId = ownership.kind === "task" ? ownership.taskId : void 0; + const id = reservedId ?? await this.#host.storage.mintId(); + this.#assertOpen(); + let owner; + if (ownerTaskId !== void 0) { + const task = await this.#currentTask(ownerTaskId); + this.#assertOpen(); + if (task === void 0) + throw new Error(`Conversation owner task ${ownerTaskId} does not exist`); + owner = { conversationId: task.conversationId, taskId: ownerTaskId }; + } + const record = { + id, + ...parent === void 0 ? {} : { parent }, + ...owner === void 0 ? {} : { owner } + }; + const copies = parent === void 0 ? [] : await prepareForkDocumentCopies(this.#host.storage, parent.conversationId, parent.at, id, this.#context); + this.#assertOpen(); + for (const copy2 of copies) { + this.#forkSourceDocumentIds.add(copy2.source.id); + const entry2 = { + addressId: addressId(copy2.record), + address: copy2.record, + target: { kind: "fork-copy", ...copy2 }, + retireOnCommit: false + }; + this.#documents.push(entry2); + this.#latestDocumentByAddress.set(entry2.addressId, entry2); + } + if (parent !== void 0) + this.#forkSourceConversationIds.add(parent.conversationId); + this.#createdConversationIds.add(id); + this.#writes.push({ type: "conversation", value: record }); + await this.#host.conversationCreated(this, record); + this.#assertOpen(); + return record; + } + appendEntry(first, second, third) { + if (typeof first === "number") + return this.#appendEntry(first, second); + return this.#appendEntry(second, { ...third, kind: first.kind }); + } + #appendEntry(conversationId, value2) { + return this.#write(async () => { + await this.#requireConversation(conversationId); + this.#assertOpen(); + const id = await this.#host.storage.mintId(); + this.#assertOpen(); + const { head, ...rest } = value2; + const record = copyJson({ + ...rest, + id, + conversationId, + head: head === "self" ? id : head, + byTaskId: this.#scope.taskId + }, TABLE_JSON_COPY_OPTIONS); + this.#writes.push({ type: "entry", value: record }); + return record; + }); + } + createTask(task, input, options) { + return this.#write(async () => { + if (options.submissionIds !== void 0) { + let trace; + let links; + for (let index = 0; index < options.submissionIds.length; index++) { + const id2 = options.submissionIds[index]; + const submission2 = this.#submissions.get(id2) ?? await this.#host.storage.submission(id2, this.#context); + if (index === 0) + trace = submission2?.trace ?? "root"; + else if (submission2?.trace !== void 0) { + links ??= []; + links.push(submission2.trace); + } + } + options = { ...options, trace, links }; + } + const ownership = options.ownership; + let owner; + if (ownership.kind === "task") { + owner = await this.#currentTask(ownership.taskId); + this.#assertOpen(); + if (owner === void 0) + throw new Error(`Task owner ${ownership.taskId} does not exist`); + if (options.background === true) + throw new TypeError("A child task cannot be background"); + if (options.conversationId !== void 0 && options.conversationId !== owner.conversationId) { + throw new Error(`A child task lives in its owner's conversation ${owner.conversationId}`); + } + } + const conversationId = owner?.conversationId ?? options.conversationId ?? this.#scope.conversationId; + if (conversationId === void 0) + throw new TypeError("Tx.createTask() requires options.conversationId"); + await this.#requireConversation(conversationId); + this.#assertOpen(); + const definition = task.definition; + const checkpoint = definition.initial(input); + const id = await this.#host.storage.mintId(); + this.#assertOpen(); + const record = copyJson({ + id, + conversationId, + kind: definition.name, + version: definition.version, + input, + trace: options.trace === "root" ? void 0 : options.trace ?? this.#context.telemetry.propagation?.inject(this.#context.telemetry), + links: options.links, + ...owner === void 0 ? {} : { owner: owner.id }, + background: options.background ?? false, + abortRequested: false, + ...options.abandonOnRestart === true ? { abandonOnRestart: true } : {}, + state: { status: "pending", checkpoint } + }, TABLE_JSON_COPY_OPTIONS); + this.#tasksById.set(id, { write: { kind: "create", record } }); + return id; + }); + } + /** Create a raw submission record with a fresh ID; no admission rules apply. */ + createSubmission(create) { + return this.#write(async () => { + await this.#requireConversation(create.conversationId); + this.#assertOpen(); + const id = await this.#host.storage.mintId(); + this.#assertOpen(); + const record = copyJson({ ...create, id }, TABLE_JSON_COPY_OPTIONS); + this.#submissions.set(id, record); + return record; + }); + } + /** + * Settle a submission. Resolved during assembly against the transaction's latest candidate record, falling back to + * committed state, so it is not a caller table read and works after the first table write. + */ + settleSubmission(id, settlement) { + this.#assertOpen(); + this.#hasTableWrite = true; + this.#submissionChanges.push({ id, change: copyJson(settlement) }); + } + /** Place a queued submission at `entry`; resolved during assembly like `settleSubmission()`. */ + placeSubmission(id, entry2) { + this.#assertOpen(); + this.#hasTableWrite = true; + this.#submissionChanges.push({ id, change: { status: "placed", entry: entry2 } }); + } + /** Internal: replace one task record completely. Tasks change their own state through their runtime. */ + setTask(value2) { + this.#assertOpen(); + this.#hasTableWrite = true; + const task = this.#taskEntry(value2.id); + const candidate = task.write?.record; + if (candidate?.state.status === "terminal") { + throw new Error(`Task ${value2.id} already has a terminal candidate`); + } + if (candidate !== void 0 && candidate.conversationId !== value2.conversationId) { + throw new Error(`Task ${value2.id} cannot change conversations`); + } + task.write = { + kind: task.write?.kind === "create" ? "create" : "replace", + record: copyJson(this.#stampTimes(value2, candidate), TABLE_JSON_COPY_OPTIONS) + }; + } + /** Internal: candidate records of the tasks this transaction created or replaced so far. */ + stagedTasks() { + const records = []; + for (const task of this.#tasksById.values()) + if (task.write !== void 0) + records.push(task.write.record); + return records; + } + /** Internal: IDs of the tasks this transaction creates. */ + createdTaskIds() { + const ids = []; + for (const [id, task] of this.#tasksById) + if (task.write?.kind === "create") + ids.push(id); + return ids; + } + /** Internal: conversations this transaction created or forked so far. */ + stagedConversations() { + const records = []; + for (const write of this.#writes) + if (write.type === "conversation") + records.push(write.value); + return records; + } + doc(token, ...args) { + try { + this.#assertOpen(); + const definition = token.definition; + const resolved = resolveAddress(definition, args); + this.#assertTaskDocumentsOpen(resolved); + const latest = this.#latestDocumentByAddress.get(resolved.id); + if (latest !== void 0 && !latest.retireOnCommit) { + if (latest.draftPromise !== void 0) + return latest.draftPromise; + if (latest.target?.kind === "fork-copy") { + latest.draftPromise = this.#track(this.#acquireForkCopy(latest, definition, latest.target)); + return latest.draftPromise; + } + } + const seed = definition.family === true ? copyJson(args[resolved.nextArgument]) : void 0; + const docEntry = { + addressId: resolved.id, + address: resolved.address, + definition, + retireOnCommit: false + }; + this.#documents.push(docEntry); + this.#latestDocumentByAddress.set(docEntry.addressId, docEntry); + docEntry.draftPromise = this.#track(this.#acquire(docEntry, seed, latest?.retireOnCommit === true)); + return docEntry.draftPromise; + } catch (error) { + return Promise.reject(error); + } + } + retireDoc(token, ...args) { + try { + this.#assertOpen(); + const definition = token.definition; + const resolved = resolveAddress(definition, args); + const latest = this.#latestDocumentByAddress.get(resolved.id); + if (latest?.retireOnCommit) + return Promise.resolve(); + if (latest?.target?.kind === "fork-copy") { + checkRecordScope(definition, latest.target.record); + latest.retireOnCommit = true; + return Promise.resolve(); + } + if (latest?.draftPromise !== void 0) { + latest.retireOnCommit = true; + return this.#track(latest.draftPromise.then(() => void 0)); + } + const entry2 = { + addressId: resolved.id, + address: resolved.address, + definition, + retireOnCommit: true + }; + this.#documents.push(entry2); + this.#latestDocumentByAddress.set(entry2.addressId, entry2); + return this.#track(this.#findRetirement(entry2)); + } catch (error) { + return Promise.reject(error); + } + } + async #acquire(entry2, seed, skipLoad) { + const definition = entry2.definition; + const loaded = skipLoad ? void 0 : await this.#host.load(definition, entry2.addressId, entry2.address, this.#context); + this.#assertOpen(); + if (loaded !== void 0) { + checkRecordScope(definition, loaded.record); + checkRecordVersion(definition, loaded.record, loaded.storedVersion); + entry2.target = { kind: "loaded", document: loaded }; + entry2.change = loaded.tracker.beginChange(); + return entry2.change.state; + } + const scope = entry2.address.scope; + if (scope.kind === "conversation") + await this.#requireConversation(scope.conversationId); + if (scope.kind === "task") { + const task = await this.#currentTask(scope.taskId); + if (task === void 0) + throw new Error(`Task ${scope.taskId} does not exist`); + if (task.state.status === "terminal") + throw new Error(`Task ${scope.taskId} is terminal`); + } + this.#assertOpen(); + const value2 = copyJson(definition.family === true ? definition.initial(seed) : definition.initial()); + const id = await this.#host.storage.mintId(); + this.#assertOpen(); + const tracker = track(value2); + entry2.target = { + kind: "created", + record: documentCreate(definition, entry2.address, id), + version: definition.version, + tracker + }; + entry2.change = tracker.beginChange(); + return entry2.change.state; + } + async #acquireForkCopy(entry2, definition, target) { + const stored = await this.#host.storage.document(target.source.id, target.source.at, this.#context); + this.#assertOpen(); + if (stored === void 0) { + throw new Error(`Fork source document ${target.source.id} cannot be read`); + } + if (stored.record.scope.kind !== "conversation" || stored.record.kind !== target.record.kind || stored.record.key !== target.record.key || stored.record.history !== target.record.history || stored.record.fork !== target.record.fork) { + throw new Error(`Fork source document ${target.source.id} does not match the copied record`); + } + const value2 = materializeDocumentValue(definition, target.record, stored.version, stored.value); + const tracker = track(value2); + entry2.definition = definition; + entry2.target = { kind: "created", record: target.record, version: definition.version, tracker }; + entry2.change = tracker.beginChange(); + return entry2.change.state; + } + async #findRetirement(entry2) { + const gate = this.#host.touch(documentKey(entry2.addressId)); + if (gate !== void 0) + await gate; + const record = this.#host.cached(entry2.addressId)?.record ?? await this.#host.storage.findDocument(entry2.address, "current", this.#context); + this.#assertOpen(); + if (record === void 0) + return; + checkRecordScope(entry2.definition, record); + entry2.target = { kind: "retire-only", record }; + } + // ─── Settlement ───────────────────────────────────────────────────────── + /** Seal after callback failure: abort every change and observe every pending operation. */ + async settleFailure() { + this.#sealed = true; + this.#abortChanges(); + await Promise.allSettled(this.#pendingOperations); + this.#context = BACKGROUND_CONTEXT; + } + /** + * Seal after callback success, prepare every open change, and assemble the atomic batch. + * Any failure aborts every change before Storage admission. + */ + async settleSuccess() { + this.#sealed = true; + if (this.#pendingOperations.size > 0) { + this.#abortChanges(); + await Promise.allSettled(this.#pendingOperations); + this.#context = BACKGROUND_CONTEXT; + throw new Error("Session commit callback settled before its pending Tx operations"); + } + try { + for (const document of this.#documents) { + if (document.change !== void 0) + document.prepared = document.change.prepare(); + } + return await this.#assemble(); + } catch (error) { + this.#abortChanges(); + throw error; + } finally { + this.#context = BACKGROUND_CONTEXT; + } + } + /** Abort every prepared change after Storage failure or when no write is required. */ + discard() { + this.#abortChanges(); + } + /** + * Internal: what a deferred commit holds in memory instead of storing (`CommitPersistence`), each changed incarnation + * with its operations and whether they selected a base; `undefined` when the commit must be stored. It may be held + * only when every write changes an existing `latest`, `initial`-fork conversation document at its stored version. + */ + holdable() { + if (this.#writes.some((write) => write.type !== "document.change")) + return void 0; + const held = []; + for (const plan of this.#plans) { + const { content, change } = plan; + if (content === void 0 && !plan.retire) + continue; + if (plan.retire || change?.loaded === void 0 || content?.type !== "document.change") + return void 0; + const { loaded, prepared, version } = change; + const record = loaded.record; + if (record.scope.kind !== "conversation" || record.history !== "latest" || record.fork !== "initial") { + return void 0; + } + if (loaded.storedVersion !== version) + return void 0; + held.push({ loaded, ops: prepared.ops, base: content.content.kind === "base" }); + } + return held; + } + /** + * Internal: adopt a held commit (`holdable()`) in memory only. Trackers advance by pointer swap; stored versions and + * delta counts stay, as nothing was stored. Returns its publication for the Session's own observers. + */ + adoptHeld() { + const publications = []; + for (const plan of this.#plans) { + const { tracker, prepared, version, loaded } = plan.change; + if (prepared.ops.length === 0) { + prepared.abort(); + continue; + } + tracker.adopt(prepared); + const record = loaded.record; + const conversationId = plan.conversationId; + publications.push({ + type: "document", + kind: "change", + record, + conversationId, + version, + value: prepared.value, + ops: prepared.ops + }); + } + return publications; + } + /** + * Internal: store the held changes of every incarnation this commit reads or writes in front of its own, so a stored + * document never misses an operation its later ones build on. Called right before Storage admission, after every + * fallible step, because it empties the holds: a failure earlier keeps them. Returns the holds it took. + */ + takeHeld(held) { + const taken = []; + for (const plan of this.#plans) { + if (!("createdAt" in plan.record)) + continue; + const hold = held(plan.record.id); + if (hold === void 0) + continue; + taken.push(hold); + const own = plan.content?.type === "document.change" ? plan.content : void 0; + const change = plan.change; + const value2 = change?.prepared.value ?? hold.loaded.tracker.value; + const stored = storeHeld(hold, change?.prepared.ops ?? EMPTY_OPERATIONS, value2, hold.loaded.valueVersion); + const content = own?.content.kind === "base" ? own.content : stored.content; + const write = { type: "document.change", id: plan.record.id, content }; + if (own !== void 0) + this.#writes[this.#writes.indexOf(own)] = write; + else + this.#insertBeforeRetirement(write); + if (!plan.retire) + plan.held = { ops: stored.ops, observed: own !== void 0 }; + plan.content = write; + if (plan.record.scope.kind === "conversation") + plan.conversationId = plan.record.scope.conversationId; + } + return taken; + } + /** + * Internal: the keys this commit writes, once settled (and after `takeHeld()`), for the Session's unpublished write + * set: its table records, and the address and incarnation of every document it creates, changes, or retires, with + * the scope of every incarnation it creates or retires. + */ + writeKeys() { + const keys = []; + tableKeys(this.#writes, keys); + for (const plan of this.#plans) { + if (plan.content === void 0 && !plan.retire) + continue; + keys.push(documentKey(plan.addressId), incarnationKey(plan.record.id)); + if (plan.retire || !("createdAt" in plan.record)) + keys.push(scopeKey(plan.record.scope)); + } + return keys; + } + /** Stage a content write for an incarnation, before its retirement when it retires. */ + #insertBeforeRetirement(write) { + const at = this.#writes.findIndex((candidate) => candidate.type === "document.retire" && candidate.id === write.id); + if (at < 0) + this.#writes.push(write); + else + this.#writes.splice(at, 0, write); + } + /** Adopt every prepared change by pointer swap after Storage success and describe the publication. */ + adopt(seq) { + const publications = []; + let stored; + for (const plan of this.#plans) { + const committed = "createdAt" in plan.record; + let record = committed ? plan.record : { ...plan.record, createdAt: seq }; + if (plan.retire) + record = { ...record, retiredAt: seq }; + const change = plan.change; + if (change !== void 0) { + const { tracker, prepared, loaded, version } = change; + if (loaded === void 0 ? !plan.retire : prepared.ops.length > 0) + tracker.adopt(prepared); + else + prepared.abort(); + if (loaded !== void 0) { + if (loaded.storedVersion < version) + loaded.storedVersion = version; + if (plan.content?.type === "document.change") { + if (plan.content.content.kind === "base") + loaded.deltasSinceBase = 0; + else + loaded.deltasSinceBase++; + } + } else if (!plan.retire) { + this.#host.install({ + addressId: plan.addressId, + record, + storedVersion: version, + valueVersion: version, + deltasSinceBase: 0, + tracker + }); + } + } + if (plan.retire && committed) + this.#host.evict(plan.addressId, record.id); + const publication = describe(plan, record); + if (publication === void 0) + continue; + const held = plan.held; + if (held === void 0) { + publications.push(publication); + stored?.push(publication); + continue; + } + stored ??= [...publications]; + if (held.observed) + publications.push(publication); + stored.push(publication.type === "document" && publication.kind === "change" ? { ...publication, ops: held.ops } : publication); + } + return { observed: publications, stored: stored ?? publications }; + } + async #assemble() { + const storage = this.#host.storage; + const plans = this.#plans; + for (const document of this.#documents) { + const plan = planDocument(document); + if (plan !== void 0) + plans.push(plan); + } + this.#rejectForkSourceWrites(plans); + await this.#validateOwners(); + for (const [id, task] of this.#tasksById) { + if (task.write?.kind !== "replace") + continue; + const committed = await this.#committedTask(id); + if (committed === void 0) + throw new Error(`Task ${id} does not exist`); + if (committed.state.status === "terminal") + throw new Error(`Task ${id} is already terminal`); + if (committed.conversationId !== task.write.record.conversationId) { + throw new Error(`Task ${id} cannot change conversations`); + } + } + const terminalTaskIds = /* @__PURE__ */ new Set(); + for (const task of this.#tasksById.values()) { + if (task.write?.record.state.status === "terminal") + terminalTaskIds.add(task.write.record.id); + } + if (terminalTaskIds.size > 0) { + const retiring = /* @__PURE__ */ new Set(); + for (const plan of plans) { + const scope = plan.record.scope; + if (scope.kind !== "task" || !terminalTaskIds.has(scope.taskId)) + continue; + plan.retire = true; + retiring.add(plan.record.id); + } + const retire = (record) => { + if (retiring.has(record.id)) + return; + plans.push({ addressId: addressId(record), record, retire: true }); + retiring.add(record.id); + }; + for (const taskId of terminalTaskIds) { + if (this.#tasksById.get(taskId)?.write?.kind === "create") + continue; + const gate = this.#host.touch(scopeKey({ kind: "task", taskId })); + if (gate !== void 0) + await gate; + const known = this.#host.taskDocuments(taskId); + if (known !== void 0) { + for (const record of known) + retire(record); + continue; + } + let cursor; + do { + const page2 = await storage.scanDocuments({ scope: { kind: "task", taskId }, at: "current" }, INTERNAL_SCAN_PAGE_SIZE, cursor, this.#context); + for (const record of page2.items) + retire(record); + cursor = page2.next; + } while (cursor !== void 0); + } + } + for (const plan of plans) { + if (!publishes(plan)) + continue; + const scope = plan.record.scope; + if (scope.kind === "conversation") + plan.conversationId = scope.conversationId; + if (scope.kind !== "task") + continue; + const task = this.#taskEntry(scope.taskId); + if (task.publicationConversationId === void 0) { + const current = await this.#currentTask(scope.taskId); + if (current !== void 0) + task.publicationConversationId = current.conversationId; + } + plan.conversationId = task.publicationConversationId; + } + for (const { id, change } of this.#submissionChanges) { + const current = this.#submissions.get(id) ?? await storage.submission(id, this.#context); + if (current === void 0) + throw new Error(`Submission ${id} does not exist`); + const next = applySubmissionChange(current, change); + if (next !== current) + this.#submissions.set(id, next); + } + const writes = this.#writes; + for (const value2 of this.#submissions.values()) + writes.push({ type: "submission", value: value2 }); + for (const task of this.#tasksById.values()) { + if (task.write !== void 0) + writes.push({ type: "task", value: task.write.record }); + } + for (const plan of plans) { + const change = plan.change; + if (plan.content?.type === "document.change" && plan.content.content.kind === "delta" && change?.loaded) { + const { prepared, version } = change; + const info = { deltasSinceBase: change.loaded.deltasSinceBase }; + if (change.definition.checkpointWhen(prepared.value, prepared.ops, info)) { + plan.content = { ...plan.content, content: { version, kind: "base", value: prepared.value } }; + } + } + if (plan.content !== void 0) + writes.push(plan.content); + if (plan.retire) + writes.push({ type: "document.retire", id: plan.record.id }); + } + return writes; + } + // ─── Helpers ──────────────────────────────────────────────────────────── + /** + * New owned work needs a live owner, judged on the owner's final candidate: not `completing`, terminal, or + * abort-marked. A task therefore cannot create owned work in the commit that finishes it (spec §5.5). + */ + async #validateOwners() { + const owners = []; + for (const write of this.#writes) { + if (write.type !== "conversation" || write.value.owner === void 0) + continue; + owners.push({ what: "Conversation owner task", taskId: write.value.owner.taskId }); + } + for (const task of this.#tasksById.values()) { + const record = task.write?.kind === "create" ? task.write.record : void 0; + if (record?.owner !== void 0) + owners.push({ what: "Task owner", taskId: record.owner }); + } + for (const { what, taskId } of owners) { + const task = await this.#currentTask(taskId); + if (task === void 0) + throw new Error(`${what} ${taskId} does not exist`); + if (task.state.status === "terminal" || task.state.status === "completing") { + throw new Error(`${what} ${taskId} is ${task.state.status}`); + } + if (task.abortRequested) + throw new Error(`${what} ${taskId} is abort-marked`); + } + } + #rejectForkSourceWrites(plans) { + for (const plan of plans) { + if (plan.content === void 0 && !plan.retire) + continue; + const record = plan.record; + if (this.#forkSourceDocumentIds.has(record.id)) { + throw new Error(`Cannot change fork source document ${record.id} in the fork transaction`); + } + const scope = plan.record.scope; + if (scope.kind === "conversation" && this.#forkSourceConversationIds.has(scope.conversationId) && record.scope.kind === "conversation" && record.fork === "current") { + throw new Error(`Cannot fork conversation ${scope.conversationId} while changing its current-policy documents`); + } + } + } + #abortChanges() { + for (const document of this.#documents) + document.change?.abort(); + } + #assertOpen() { + if (this.#sealed) + throw new Error("Transaction has settled"); + } + #assertTaskDocumentsOpen(resolved) { + const scope = resolved.address.scope; + if (scope.kind === "task" && this.#tasksById.get(scope.taskId)?.write?.record.state.status === "terminal") { + throw new Error(`Task ${scope.taskId} is terminal`); + } + } + /** Register an operation so callback settlement can reject and drain it. */ + #track(operation) { + this.#pendingOperations.add(operation); + const settle3 = () => { + this.#pendingOperations.delete(operation); + }; + operation.then(settle3, settle3); + return operation; + } + #read(method, read2) { + try { + this.#assertOpen(); + if (this.#hasTableWrite) + throw new ReadAfterWrite(`Tx.${method}()`); + return this.#track(read2()); + } catch (error) { + return Promise.reject(error); + } + } + #write(write) { + try { + this.#assertOpen(); + this.#hasTableWrite = true; + return this.#track(write()); + } catch (error) { + return Promise.reject(error); + } + } + async #requireConversation(id) { + if (this.#createdConversationIds.has(id)) + return; + if (await this.#host.storage.conversation(id, this.#context) === void 0) { + throw new Error(`Conversation ${id} does not exist`); + } + } + /** + * Lifecycle times: `startedAt` on the first change to `running`, `endedAt` on the change to `terminal`. Once set, + * they carry over from the replaced record; records written before they existed lack them. + */ + #stampTimes(value2, candidate) { + const status = value2.state.status; + const now = (stamps) => stamps ? this.#host.now() : void 0; + const startedAt = candidate?.startedAt ?? value2.startedAt ?? now(status === "running"); + const endedAt = candidate?.endedAt ?? value2.endedAt ?? now(status === "terminal"); + if (startedAt === value2.startedAt && endedAt === value2.endedAt) + return value2; + return { + ...value2, + ...startedAt === void 0 ? {} : { startedAt }, + ...endedAt === void 0 ? {} : { endedAt } + }; + } + #taskEntry(id) { + let task = this.#tasksById.get(id); + if (task === void 0) { + task = {}; + this.#tasksById.set(id, task); + } + return task; + } + /** Latest candidate task record, falling back to committed state; not a caller table read. */ + async #currentTask(id) { + return this.#tasksById.get(id)?.write?.record ?? await this.#committedTask(id); + } + #committedTask(id) { + const task = this.#taskEntry(id); + task.committedRead ??= this.#host.storage.task(id, this.#context); + return task.committedRead; + } +}; +function planDocument(document) { + const target = document.target; + if (target === void 0) + return void 0; + const plan = { addressId: document.addressId, retire: document.retireOnCommit }; + switch (target.kind) { + case "created": { + const prepared = document.prepared; + const { record, version, tracker } = target; + const content = { version, kind: "base", value: prepared.value }; + return { + ...plan, + record, + content: { type: "document.create", record, content }, + change: { tracker, prepared, version } + }; + } + case "fork-copy": + return { + ...plan, + record: target.record, + content: { type: "document.copy", record: target.record, source: target.source } + }; + case "retire-only": + return { ...plan, record: target.record }; + case "loaded": { + const loaded = target.document; + const definition = document.definition; + const prepared = document.prepared; + const version = definition.version; + const id = loaded.record.id; + const content = loaded.storedVersion < version ? { type: "document.change", id, content: { version, kind: "base", value: prepared.value } } : prepared.ops.length > 0 ? { type: "document.change", id, content: { version, kind: "delta", ops: prepared.ops } } : void 0; + const change = { + tracker: loaded.tracker, + prepared, + version, + loaded, + definition + }; + return { ...plan, record: loaded.record, change, ...content === void 0 ? {} : { content } }; + } + } +} +function describe(plan, record) { + const conversationId = plan.conversationId; + if (plan.retire) + return { type: "document", kind: "retire", record, conversationId }; + if (plan.content?.type === "document.copy") { + return { type: "document.copy", record, conversationId, source: plan.content.source }; + } + const change = plan.change; + if (change === void 0 || !publishes(plan)) + return void 0; + const ops = change.loaded === void 0 ? EMPTY_OPERATIONS : change.prepared.ops; + return { + type: "document", + kind: "change", + record, + conversationId, + version: change.version, + value: change.prepared.value, + ops + }; +} +function storeHeld(hold, ops, value2, version) { + hold.pending.push(ops); + const taken = hold.pending.take(); + if (taken.kind === "replace") + return { content: { version, kind: "base", value: value2 }, ops: [["r", value2]] }; + if (hold.base) + return { content: { version, kind: "base", value: value2 }, ops: taken.ops }; + return { content: { version, kind: "delta", ops: taken.ops }, ops: taken.ops }; +} +function publishes(plan) { + return plan.retire || plan.change?.loaded === void 0 || plan.content !== void 0; +} + +// node_modules/@earendil-works/pi-durable/dist/session/writer.js +var SESSION_FAILED = { kind: "failed" }; +var Writer = class { + #host; + /** Admitted parts not yet sent, in line order. */ + #queue = []; + #flight = false; + /** Per key, the newest unpublished part that writes it. */ + #unpublished = /* @__PURE__ */ new Map(); + /** The newest admitted part that is not yet published, whose gate opens after every earlier one's. */ + #newest; + constructor(host) { + this.#host = host; + } + /** Admit a part: register its keys, queue it, and send it at once unless a call is in flight. */ + admit(spec) { + const outcome = Promise.withResolvers(); + outcome.promise.catch(() => { + }); + const part = { + ...spec, + outcome: outcome.promise, + settle: { + resolve: () => { + part.done = true; + outcome.resolve(); + }, + reject: (error) => { + part.done = true; + outcome.reject(error); + } + }, + done: false, + gate: Promise.withResolvers() + }; + for (const key of spec.keys) + this.#unpublished.set(key, part); + this.#newest = part; + this.#queue.push(part); + this.#pump(); + return part; + } + /** The gate of the newest unpublished writer of `key`, or `undefined` when it has none. */ + gate(key) { + return this.#unpublished.get(key)?.gate.promise; + } + /** Opens once every part admitted so far is published or never will be. */ + drained() { + return this.#newest?.gate.promise; + } + /** Keys with an unpublished writer, for tests that bound memory. */ + get unpublished() { + return this.#unpublished.size; + } + #pump() { + if (this.#flight || this.#queue.length === 0) + return; + this.#flight = true; + const parts = this.#queue; + this.#queue = []; + void this.#flush(parts); + } + /** + * Send and publish `parts`, then what was admitted meanwhile. Never rejects: a throw the writer did not expect fails + * the Session and settles the parts (`#abandon()`), so no outcome or gate is stranded. + */ + async #flush(parts) { + try { + await this.#send(parts); + } catch (error) { + this.#abandon(parts, error); + } + this.#flight = false; + this.#pump(); + } + /** Store `parts` in one call, then publish them in order; settles every one of them. */ + async #send(parts) { + const stored = parts.filter((part) => part.writes !== void 0); + const seqs = []; + let result; + let rejected; + const failed = this.#host.failure(); + if (failed !== void 0) { + for (const part of parts) + this.#unpublish(part, new SessionFailed(failed.error)); + return; + } + if (stored.length > 0) { + try { + result = await this.#host.commit(stored.map((part) => part.writes), (index, seq) => { + seqs[index] = seq; + }, withoutAbortSignal(stored[0].context)); + } catch (error) { + rejected = { error }; + } + } + const failedMeanwhile = this.#host.failure(); + if (failedMeanwhile !== void 0) { + for (const part of parts) + this.#unpublish(part, new SessionFailed(failedMeanwhile.error)); + return; + } + await this.#publish(parts, seqs, result, rejected); + } + /** + * Publish the received parts and held ones in order, a macrotask apart, and settle every part (group commit §3.4). A + * refusal, a rejection, or a part whose adoption or own observer throws fails the Session once the parts before it + * are published and the host observers got the last one; the parts after it are not published, and reject with + * `SessionFailed`. What the failure decides (the outcomes, and the gates of the parts it concerns) is settled only + * once the Session failed, so no job waiting at such a gate, and no later outcome, goes on as if it had not. + */ + async #publish(parts, seqs, result, rejected) { + let stop2; + const failing = []; + let storedIndex = 0; + let published = 0; + for (const part of parts) { + const index = part.writes === void 0 ? void 0 : storedIndex++; + if (stop2 !== void 0) { + const outcome = rejected !== void 0 && index !== void 0 && stop2.external !== true ? { kind: "own", error: rejected.error } : SESSION_FAILED; + failing.push({ part, outcome, published: false }); + continue; + } + const seq = index === void 0 ? void 0 : seqs[index]; + if (index !== void 0 && seq === void 0) { + const error = rejected !== void 0 ? rejected.error : index === result?.stored && "refused" in result ? result.refused : new Error("Storage committed without adopting the batch"); + failing.push({ part, outcome: { kind: "own", error }, published: false }); + stop2 = { error, at: part.index }; + continue; + } + if (published++ > 0) + await hop(); + let failure2; + let external; + const publication = this.#host.publishing(() => { + external = this.#host.failure(); + if (external !== void 0) + return; + this.#unregister(part); + try { + if (part.writes !== void 0) + this.#host.stored(part.writes); + failure2 = part.publish(seq); + } catch (error) { + failure2 = { kind: "adoption", error }; + } + }, part.context); + if (publication !== void 0) + await publication; + if (external !== void 0) { + stop2 = { error: external.error, at: part.index, external: true }; + failing.push({ part, outcome: SESSION_FAILED, published: false }); + continue; + } + if (failure2?.kind === "adoption") { + failing.push({ part, outcome: { kind: "own", error: failure2.error }, published: true }); + stop2 = { error: failure2.error, at: part.index }; + continue; + } + if (rejected !== void 0) { + if (failure2 !== void 0) + this.#host.report(failure2.error, part.context); + failing.push({ part, outcome: { kind: "own", error: rejected.error }, published: true }); + continue; + } + if (failure2 !== void 0) + stop2 = { error: failure2.error, at: part.index }; + const failedMeanwhile = this.#host.failure(); + if (failedMeanwhile !== void 0 && failure2 === void 0) { + part.settle.reject(new SessionFailed(failedMeanwhile.error)); + } else + part.settle.resolve(); + this.#open(part); + } + if (rejected !== void 0 || stop2 !== void 0) { + await Promise.resolve(); + const own = failing.flatMap(({ part, outcome }) => outcome.kind === "own" ? [part.index] : []); + if (rejected !== void 0) + this.#host.fail(rejected.error, own[0], own); + else + this.#host.fail(stop2.error, stop2.at, own); + } + for (const { part, outcome, published: stored } of failing) { + const error = outcome.kind === "own" ? outcome.error : this.#failed(stop2.error); + if (stored) { + part.settle.reject(error); + this.#open(part); + } else + this.#unpublish(part, error); + } + const failure = this.#host.failure(); + if (failure !== void 0) { + const queued = this.#queue; + this.#queue = []; + for (const part of queued) + this.#unpublish(part, new SessionFailed(failure.error)); + } + } + /** + * A throw the writer itself did not expect while sending or publishing `parts` (a bug): fail the Session at the first + * of them and settle every one not settled yet, so no outcome, gate, or close waits on it forever. + */ + #abandon(parts, error) { + this.#host.fail(error, parts[0].index, []); + for (const part of parts) { + if (!part.done) + this.#unpublish(part, this.#failed(error)); + this.#unregister(part); + this.#open(part); + } + } + /** `SessionFailed` with the Session's failure, or with `error` until the Session records it. */ + #failed(error) { + const failure = this.#host.failure(); + return new SessionFailed(failure !== void 0 ? failure.error : error); + } + /** Settle a part that will never be published: discard it, reject it with `error`, and open its gate. */ + #unpublish(part, error) { + part.discard(); + part.settle.reject(error); + this.#unregister(part); + this.#open(part); + } + /** Drop the keys a part is still the newest writer of: it is published, or never will be. */ + #unregister(part) { + for (const key of part.keys) + if (this.#unpublished.get(key) === part) + this.#unpublished.delete(key); + } + /** Open a part's gate: the jobs waiting for it go on. The newest part's opens `drained()` too. */ + #open(part) { + part.gate.resolve(); + if (this.#newest === part) + this.#newest = void 0; + } +}; +function hop() { + if (typeof setImmediate === "function") + return new Promise((resolve2) => setImmediate(resolve2)); + const channel = new MessageChannel(); + return new Promise((resolve2) => { + channel.port1.onmessage = () => { + channel.port1.close(); + resolve2(); + }; + channel.port2.postMessage(void 0); + }); +} + +// node_modules/@earendil-works/pi-durable/dist/session/session.js +var NO_HOLDS = []; +var NO_OPERATIONS = []; +var NO_KEYS = []; +var MAX_OUTSTANDING = 256; +var ignore = () => { +}; +var SETTLED = Promise.resolve(); +var LISTENER = { kind: "listener_error" }; +var TELEMETRY = { kind: "host_error", callback: "telemetry" }; +var defaultRetention = 6e5; +function documentRetention(ms) { + return typeof ms === "number" && ms >= 0 ? ms : defaultRetention; +} +var DEFAULT_PERSIST_WITHIN_MS = 1e3; +function persistWithin(ms) { + return typeof ms === "number" && ms >= 0 ? ms : DEFAULT_PERSIST_WITHIN_MS; +} +var SessionImpl = class { + #storage; + /** Newest entry and head marker per conversation; none for a read-only Session, whose Storage another process writes. */ + #tails; + /** + * The current documents of each live task this Session created, so the commit that ends a task retires them without + * a scan. An entry starts with its task's creation and goes when the task is terminal: bounded by live tasks. + */ + #taskDocuments = /* @__PURE__ */ new Map(); + #documents = /* @__PURE__ */ new Map(); + /** Cached documents with no observer attached, by address, oldest use first, with the time of that use. */ + #idleDocuments = /* @__PURE__ */ new Map(); + /** Observers attached per document incarnation (record ID); a cached incarnation with any is never evicted. */ + #attachedDocuments = /* @__PURE__ */ new Map(); + #clock; + #documentRetentionMs; + #persistWithinMs; + /** Documents with held changes, by incarnation (record ID); each is pinned in the cache until they are stored. */ + #holds = /* @__PURE__ */ new Map(); + #holdTimer; + /** Refuse every commit with writes (`HarnessOptions.readOnly`). */ + #readOnly; + #timers; + /** The pending eviction: a timer for the oldest idle document, or a line job when the retention is 0. */ + #documentSweep; + #sweepJobs = 0; + /** Set once the host declined a background timer: then none is kept, and the end of each commit evicts what expired. */ + #documentTimersUnavailable = false; + /** Host commit observers (`observeCommits()`), each behind `guardObserver()`. */ + #commitListeners = /* @__PURE__ */ new Set(); + /** The Session's own listeners, such as its scheduler's: they keep memory in step with storage, so a throw fails it. */ + #internalListeners = /* @__PURE__ */ new Set(); + #closeListeners = /* @__PURE__ */ new Set(); + #host; + /** The record cache under the guard, which the writer applies each batch to at its publication. */ + #cached; + #writer; + /** The Storage jobs on the line read through: each read waits while its key has an unpublished writer. */ + #gated; + /** The end of the last line job: the line runs one job at a time. */ + #tail = Promise.resolve(); + /** Line index of the next job; outcomes are delivered in this order. */ + #nextIndex = 0; + /** The job on the line, while one runs: its index, and whether it waited at a gate. */ + #current; + /** Settles once every job so far has its outcome delivered. */ + #fence = Promise.resolve(); + #lastDelivered = -1; + /** Jobs that started on the line and have no outcome delivered yet (`MAX_OUTSTANDING`). */ + #outstanding = 0; + #room; + /** The line index whose outcome the failure is: every later job's outcome is `SessionFailed`, except `#ownOutcomes`. */ + #failAt; + /** Later jobs whose outcome is their own error all the same: the other parts of a storage call that rejected. */ + #ownOutcomes = /* @__PURE__ */ new Set(); + #closing; + #failure; + /** Set when close reaches the backend: calls that fail from then on fail because it closes. */ + #failed = Promise.withResolvers(); + #closed = Promise.withResolvers(); + get closed() { + return this.#closed.promise; + } + /** Internal: the error that failed the Session, if one did. */ + get failure() { + return this.#failure; + } + /** Internal: rejects with `SessionFailed` the moment the Session fails, before work underway has ended; never resolves. */ + get failed() { + return this.#failed.promise; + } + constructor(storage, now = Date.now, documentRetentionMs = () => void 0, setTimer, readOnly = false, persistWithinMs = () => void 0) { + this.#failed.promise.catch(() => { + }); + this.#readOnly = readOnly; + this.#cached = readOnly ? void 0 : new CachedStorage(storage); + this.#storage = new GuardedStorage(this.#cached ?? storage, this); + this.#gated = new GatedStorage(this.#storage, (key) => this.touch(key)); + this.#writer = new Writer({ + commit: (batches, receive, context) => this.#storage.commit(batches, receive, context), + stored: (writes) => { + this.#cached?.published(writes); + this.#tails?.apply(writes); + }, + publishing: (publish2, context) => this.#publishing(publish2, context), + failure: () => this.#failure, + fail: (error, at, own) => this.fail(error, void 0, at, own), + report: (error, context) => this.report(error, LISTENER, context) + }); + this.#tails = readOnly ? void 0 : new ConversationTails(); + this.#timers = hostTimers(setTimer, (error) => this.report(error, { kind: "host_error", callback: "setTimer" })); + this.#clock = () => { + try { + return now(); + } catch { + return Date.now(); + } + }; + this.#documentRetentionMs = () => { + try { + return documentRetention(documentRetentionMs()); + } catch { + return documentRetention(void 0); + } + }; + this.#persistWithinMs = () => { + try { + return persistWithin(persistWithinMs()); + } catch { + return persistWithin(void 0); + } + }; + this.#host = { + storage: this.#gated, + touch: (key) => this.touch(key), + now, + cached: (id) => { + const cached = this.#documents.get(id); + if (cached !== void 0) + this.#touchDocument(id); + return cached; + }, + load: (definition, addressId2, address, context) => this.#loadDocument(definition, addressId2, address, context), + install: (document) => { + this.#documents.set(document.addressId, document); + this.#touchDocument(document.addressId); + }, + evict: (id, recordId) => { + if (this.#documents.get(id)?.record.id === recordId) + this.#dropDocument(id); + }, + conversationCreated: (tx, record) => this.conversationCreated(tx, record), + taskDocuments: (taskId) => this.#taskDocuments.get(taskId)?.values(), + latestEntryId: (conversationId, context) => this.latestEntryId(conversationId, context), + latestHeadMarker: (conversationId, context) => this.latestHeadMarker(conversationId, void 0, context) + }; + } + /** The Storage behind the failure guard; every component reads and writes through it. */ + get storage() { + return this.#storage; + } + /** + * Internal, on the Session line: the newest entry ID visible from `conversationId`. Known from the Session's own + * commits when it owns its Storage, so it costs no Storage read after the first. + */ + latestEntryId(conversationId, context) { + const gate = this.#touchEntries(conversationId); + const latest = () => { + const read2 = async () => (await this.#storage.scanEntries({ conversationId }, 1, void 0, context)).items[0]?.id; + return this.#tails === void 0 ? read2() : this.#tails.latest(conversationId, read2); + }; + return gate === void 0 ? latest() : gate.then(latest); + } + /** + * Internal: the newest entry ID of `conversationId` as known from the Session's commits right now, without reading; + * `undefined` when not known (and always for a read-only Session). + */ + knownLatestEntryId(conversationId) { + return this.#tails?.peek(conversationId); + } + /** + * Internal, on the Session line: the newest visible head marker of `conversationId` at or before `atOrBefore`, any + * when `undefined`; known from the Session's own commits as `latestEntryId()` is. + */ + latestHeadMarker(conversationId, atOrBefore, context) { + const gate = this.#touchEntries(conversationId); + const marker = () => { + const read2 = () => this.#storage.findLatestHeadMarker(conversationId, atOrBefore, context); + return this.#tails === void 0 ? read2() : this.#tails.marker(conversationId, atOrBefore, read2); + }; + return gate === void 0 ? marker() : gate.then(marker); + } + /** + * On the line: wait for what an unpublished commit wrote of a conversation's entries, its appends and its creation, + * as reading them from Storage fails for a conversation not stored yet. + */ + #touchEntries(conversationId) { + const created = this.touch(conversationKey(conversationId)); + const appended = this.touch(appendKey(conversationId)); + if (created === void 0) + return appended; + return appended === void 0 ? created : Promise.all([created, appended]).then(ignore); + } + /** + * Internal, on the line: wait, while `key` has an unpublished writer (for `ALL_KEYS`, while anything is unpublished), + * until it is published, then check that the Session is healthy; `undefined` when there is nothing to wait for. A + * job's every read touches its key first, so it never observes state an earlier job admitted and the writer has not + * published. + */ + touch(key) { + const gate = key === ALL_KEYS ? this.#writer.drained() : this.#writer.gate(key); + if (gate === void 0) + return void 0; + if (this.#current !== void 0) + this.#current.hit = true; + return gate.then(() => this.#assertHealthy()); + } + commit(change, context, options) { + return this.commitWith(change, context, void 0, options); + } + /** + * Internal: fail the Session with `error`, the first wins, and report it once. Admission ends, and close listeners run + * now; they read the error from `failure`. The Storage guard calls this; the Harness also calls it for a throw in its + * scheduler's own commits. `at` is the line index of the job whose outcome the failure is, by default the job on the + * line: later jobs' outcomes become `SessionFailed`. + */ + fail(error, context, at, own) { + if (this.#failure !== void 0) + return; + this.#failure = { error }; + this.#failAt = at ?? this.#current?.index ?? this.#lastDelivered; + if (own !== void 0) + for (const index of own) + this.#ownOutcomes.add(index); + this.#failed.reject(new SessionFailed(error)); + this.close(BACKGROUND_CONTEXT).catch(() => { + }); + this.report(error, { kind: "storage_failed" }, context); + } + /** + * Internal commit exposing the concrete transaction and its internal operations, such as the reserved-ID root + * bootstrap and task replacement. `scope` sets the default `tx.createTask()` conversation and the task attributed to + * appended entries. + */ + commitWith(change, context, scope, options) { + let withinMs; + try { + this.assertUsable(); + withinMs = this.#deferral(options?.persistence); + } catch (error) { + return Promise.reject(error); + } + return this.#enqueue((index) => this.#runCommit(change, context, scope, withinMs, index)); + } + /** + * Internal: `commitWith()` for a commit of the scheduler's own whose outcome goes only to scheduler code. When it + * writes nothing and waited at no gate, it is equivalent to having run before every unpublished commit, so its + * outcome is delivered at once, ahead of theirs. One that writes is never exempt: its outcome waits for its own + * publication and every earlier outcome, so no invocation starts on a reservation that is not durable. + */ + commitInternal(change, context) { + try { + this.assertUsable(); + } catch (error) { + return Promise.reject(error); + } + return this.#enqueue((index) => this.#runCommit(change, context, void 0, void 0, index), true); + } + /** + * Internal, on the line: call `end` once the outcome of the job on the line settles, its commit published or never to + * be; at once off the line. + */ + onJobEnd(end) { + const job = this.#current; + if (job === void 0) { + end(); + return; + } + if (job.ends === void 0) + job.ends = [end]; + else + job.ends.push(end); + } + /** Internal: the Storage jobs on the line read through (`touch()`); off the line, use the plain one. */ + get lineStorage() { + return this.#gated; + } + /** How long a commit may stay in memory before it is stored; `undefined` when it is immediate. */ + #deferral(persistence) { + if (persistence?.kind !== "deferred") + return void 0; + const ms = persistence.withinMs; + if (ms === void 0) + return this.#persistWithinMs(); + if (typeof ms !== "number" || !(ms >= 0)) { + throw new TypeError("CommitPersistence.withinMs must be a number of milliseconds, 0 or more"); + } + return ms; + } + /** Internal: whether `tx` is the transaction of one of this Session's commits. */ + ownsTransaction(tx) { + return Transaction.ownedBy(tx, this.#host); + } + /** + * Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. It reads + * through `storage`, whose reads wait for the publication of what the line admitted before; its outcome comes after + * theirs. + */ + readOnLine(job) { + try { + this.assertUsable(); + } catch (error) { + return Promise.reject(error); + } + return this.#enqueue(async () => { + this.#assertHealthy(); + return { value: await job(this.#gated) }; + }); + } + /** + * Internal: a conversation document's current incarnation and value, for a job already running on the line (see + * `readOnLine()`). Absent documents are `undefined`. + */ + async conversationDocumentOnLine(token, conversationId, context) { + const definition = token.definition; + const resolved = resolveAddress(definition, [conversationId, context]); + const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context); + if (loaded === void 0) + return void 0; + checkRecordScope(definition, loaded.record); + checkRecordVersion(definition, loaded.record, loaded.storedVersion); + return { record: loaded.record, version: loaded.valueVersion, value: loaded.tracker.value }; + } + async snapshot(token, ...args) { + this.assertUsable(); + const definition = token.definition; + const resolved = resolveAddress(definition, args); + const context = args[resolved.nextArgument]; + const cached = this.#documents.get(resolved.id); + if (cached?.valueVersion === definition.version) + this.#touchDocument(resolved.id); + const loaded = cached?.valueVersion === definition.version ? cached : await this.#enqueue(async () => { + this.#assertHealthy(); + return { value: await this.#loadDocument(definition, resolved.id, resolved.address, context) }; + }); + if (loaded === void 0) + return void 0; + checkRecordScope(definition, loaded.record); + checkRecordVersion(definition, loaded.record, loaded.storedVersion); + return loaded.tracker.value; + } + watchDoc(token, ...args) { + try { + this.assertUsable(); + const definition = token.definition; + const resolved = resolveAddress(definition, args); + const context = args[resolved.nextArgument]; + const signal = context.abortSignal; + return this.#attachDocument(definition, resolved, context, (loaded) => { + const watch = this.#observeDocument(definition, loaded, createWatch, endWatch); + if (signal !== void 0) + watch.observeAbort(signal); + return watch; + }, abortWatch); + } catch (error) { + return Promise.reject(error); + } + } + observeDoc(token, ...args) { + try { + this.assertUsable(); + const definition = token.definition; + const resolved = resolveAddress(definition, args); + const observer = args[resolved.nextArgument]; + const context = args[resolved.nextArgument + 1]; + return this.#attachDocument(definition, resolved, context, (loaded) => { + const guarded = this.#observeDocument(definition, loaded, (_value, detach, report2) => guardObserver(observer, detach, report2), endGuarded); + return observation(loaded.tracker.value, guarded); + }, detachObservation); + } catch (error) { + return Promise.reject(error); + } + } + async snapshotAsOf(token, ...args) { + this.assertUsable(); + const definition = token.definition; + const resolved = resolveAddress(definition, args); + if (resolved.address.scope.kind !== "conversation") { + throw new TypeError("Session.snapshotAsOf() requires a conversation document"); + } + const conversationId = resolved.address.scope.conversationId; + const atValue = args[resolved.nextArgument]; + if (typeof atValue !== "number" || !Number.isSafeInteger(atValue)) { + throw new TypeError("Session.snapshotAsOf() requires an entry ID"); + } + const at = idFromNumber(atValue); + const context = args[resolved.nextArgument + 1]; + return this.#enqueue(async () => { + this.#assertHealthy(); + const storedEntry = await this.#gated.entry(conversationId, at, context); + if (storedEntry === void 0) { + throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`); + } + const address = { + ...resolved.address, + scope: { kind: "conversation", conversationId: storedEntry.entry.conversationId } + }; + const record = await this.#gated.findDocument(address, storedEntry.commitSeq, context); + if (record === void 0) + return { value: void 0 }; + const stored = await this.#gated.document(record.id, storedEntry.commitSeq, context); + if (stored === void 0) { + throw new Error(`Historical document ${record.id} (${record.kind}) cannot be read`); + } + return { value: materializeDocument(definition, stored) }; + }); + } + close(context) { + if (this.#closing === void 0) { + const cleanup = withoutAbortSignal(context); + this.#closing = Promise.resolve().then(() => this.beforeClose()).then(() => this.#maintain(async () => { + await this.#drained(); + let unstored; + if (this.#failure === void 0 && this.#holds.size > 0) { + await this.#storeHolds(() => [...this.#holds.values()], cleanup).catch((error) => { + unstored = { error }; + }); + } + const observers = [...this.#commitListeners]; + this.#commitListeners.clear(); + const end = closeEnds(this.#failure).observer; + for (const observer of observers) + observer.end(end); + this.#internalListeners.clear(); + this.#documents.clear(); + this.#idleDocuments.clear(); + this.#attachedDocuments.clear(); + this.#holds.clear(); + this.#taskDocuments.clear(); + try { + await this.#storage.close(cleanup); + } catch (error) { + if (this.#failure === void 0) { + this.#failure = { error }; + this.report(error, { kind: "storage_failed" }); + } + throw error; + } + if (unstored !== void 0) + throw unstored.error; + })).finally(() => { + const failure = this.#failure; + this.#closed.resolve(failure === void 0 ? { kind: "closed" } : { kind: "failed", error: failure.error }); + }); + this.#notifyClose(); + } + return awaitWithContext(this.#closing, context); + } + /** Run the close listeners once, each on its own: one that fails is reported, and the rest still run. */ + #notifyClose() { + this.#documentSweep?.disarm?.(); + this.#documentSweep = void 0; + this.#holdTimer?.disarm(); + this.#holdTimer = void 0; + const listeners = [...this.#closeListeners]; + this.#closeListeners.clear(); + for (const listener of listeners) + contained(listener, (error) => this.report(error)); + } + /** + * Internal: the Session's failure, and errors of listeners, which never fail what ran them; `info` defaults to a + * listener. A plain Session drops them. + */ + report(_error, _info = LISTENER, _context) { + } + /** + * Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A + * plain Session stages nothing; a Harness stages its built-in documents. + */ + conversationCreated(_tx, _record) { + return Promise.resolve(); + } + /** + * Internal, for `beforeClose()`: on the line, after every commit admitted before the seal has settled, write what + * `writes()` returns then straight to Storage. Nothing is published, since the Session is closing; a failed Session + * writes nothing. + */ + writeAtClose(writes) { + return this.#maintain(async (index) => { + await this.#drained(); + if (this.#failure !== void 0) + return; + const batch = writes(); + if (batch.length === 0) + return; + const context = BACKGROUND_CONTEXT; + await this.#writer.admit({ + index, + writes: batch, + keys: [], + context, + publish: () => void 0, + discard: ignore + }).outcome; + }); + } + /** + * Keys a commit writes besides its records' (`Transaction.writeKeys()`), at its admission, on the line: a Harness + * adds the ownership memberships its task changes write. A plain Session adds none. + */ + membershipKeys(_tx) { + return NO_KEYS; + } + /** Runs after close seals admission and before the line closes Storage; must not reject. */ + beforeClose() { + return Promise.resolve(); + } + /** + * Attach a synchronous observer of every stored commit, with exactly what it stored (`CommitPublication`). Its + * `advance` runs one microtask after the commit's publication, which follows its storage call, in line order, before + * every wait the commit resolves and every abort it queues (in no order with watch listeners); it must not block or + * call Session operations. A throw is reported, detaches it, and ends it `listener_error`; `end` gets the Session's + * close after its last publication. + */ + observeCommits(observer) { + this.assertUsable(); + const guarded = guardObserver(observer, () => this.#commitListeners.delete(guarded), (error, context) => this.report(error, LISTENER, context)); + this.#commitListeners.add(guarded); + return { detach: () => guarded.detach() }; + } + /** + * Internal: follow every commit as memory adopts it (`ObservedCommit`), for the Session's own components, whose state + * follows memory. A throw leaves that state behind, so it fails the Session instead of being reported. They run at + * the commit's publication, synchronously, so they call no Storage method, run no task code, and contain every host + * callback; the waits they resolve and the aborts they queue run after host observers. + */ + followCommits(listener) { + this.assertUsable(); + this.#internalListeners.add(listener); + return () => this.#internalListeners.delete(listener); + } + /** + * Register a listener called synchronously when close begins, also when a failure closes the Session (`failure` is + * then set). It must not block or call Session operations; a throw is reported. + */ + subscribeClose(listener) { + this.assertUsable(); + this.#closeListeners.add(listener); + return () => this.#closeListeners.delete(listener); + } + /** Drop every loaded tracker on the mutation line, held changes stored first; later access cold-loads from Storage. */ + unloadDocuments() { + return this.#maintain(async () => { + if (this.#failure === void 0) + await this.#storeHolds(() => [...this.#holds.values()], BACKGROUND_CONTEXT); + this.#documents.clear(); + this.#idleDocuments.clear(); + }); + } + /** + * Internal, for tests: how many documents the cache holds, how many are idle, how many incarnations are pinned, how + * many sweep jobs were queued on the line so far, how many documents have held changes, how many live tasks have + * their documents indexed, and how many conversations have their newest entry kept. + */ + get documentCacheSizes() { + return { + cached: this.#documents.size, + idle: this.#idleDocuments.size, + attached: this.#attachedDocuments.size, + sweeps: this.#sweepJobs, + held: this.#holds.size, + taskDocuments: this.#taskDocuments.size, + tails: this.#tails?.size ?? 0 + }; + } + /** + * Internal, for tests that bound memory: jobs whose outcome is not delivered yet, keys with an unpublished writer, + * and record reads in flight in the cache. All three are 0 once the line is idle. + */ + get lineSizes() { + return { + outstanding: this.#outstanding, + unpublished: this.#writer.unpublished, + reads: this.#cached?.readsInFlight ?? 0 + }; + } + /** Note a use of a cached document: an unattached one becomes the newest idle one, and an eviction is scheduled. */ + #touchDocument(addressId2) { + const cached = this.#documents.get(addressId2); + if (cached === void 0 || this.#attachedDocuments.has(cached.record.id)) + return; + const now = this.#clock(); + const since = this.#idleDocuments.get(addressId2); + if (since !== void 0 && now - since < Math.min(1e3, this.#documentRetentionMs() / 10)) + return; + this.#idleDocuments.delete(addressId2); + this.#idleDocuments.set(addressId2, now); + if (this.#documentSweep === void 0 || this.#documentSweep.at > now + this.#documentRetentionMs()) { + this.#scheduleDocumentSweep(); + } + } + #dropDocument(addressId2) { + this.#documents.delete(addressId2); + this.#idleDocuments.delete(addressId2); + } + /** + * Schedule the eviction of the oldest idle document: on the line at once when the retention is 0, otherwise when it + * expires, by a background timer (`HarnessOptions.setTimer`), which never keeps the process alive. Where the host + * declines it, as the default does in Cloudflare Workers, none is kept: the end of each commit evicts what expired. + */ + #scheduleDocumentSweep() { + if (this.#closing !== void 0) + return; + let oldest; + for (const [addressId2, since] of this.#idleDocuments) { + if (this.#writer.gate(documentKey(addressId2)) !== void 0) + continue; + oldest = since; + break; + } + if (oldest === void 0) + return; + const at = oldest + this.#documentRetentionMs(); + if (this.#documentSweep !== void 0 && this.#documentSweep.at <= at) + return; + this.#documentSweep?.disarm?.(); + const sweep = { at, disarm: void 0 }; + const run2 = () => { + this.#sweepJobs++; + this.#maintain(async () => { + if (this.#documentSweep === sweep) + this.#documentSweep = void 0; + if (this.#closing === void 0) + this.#sweepDocuments(); + }).catch(() => { + }); + }; + const delay = at - this.#clock(); + this.#documentSweep = sweep; + if (delay <= 0) { + queueMicrotask(run2); + return; + } + if (this.#documentTimersUnavailable) { + this.#documentSweep = void 0; + return; + } + const disarm = this.#timers.background(run2, delay); + if (disarm === void 0) { + this.#documentTimersUnavailable = true; + this.#documentSweep = void 0; + return; + } + sweep.disarm = disarm; + } + /** On the line: evict the idle documents whose retention passed, oldest first, then schedule the next eviction. */ + #sweepDocuments() { + const expired = this.#clock() - this.#documentRetentionMs(); + for (const [addressId2, since] of this.#idleDocuments) { + if (since > expired) + break; + if (this.#writer.gate(documentKey(addressId2)) !== void 0) + continue; + this.#dropDocument(addressId2); + } + this.#scheduleDocumentSweep(); + } + /** Pin a cached incarnation for one observer; the returned release unpins it once, idle again after the last. */ + #pinDocument(addressId2, recordId) { + this.#attachedDocuments.set(recordId, (this.#attachedDocuments.get(recordId) ?? 0) + 1); + this.#idleDocuments.delete(addressId2); + let pinned = true; + return () => { + if (!pinned) + return; + pinned = false; + const count = (this.#attachedDocuments.get(recordId) ?? 1) - 1; + if (count > 0) { + this.#attachedDocuments.set(recordId, count); + return; + } + this.#attachedDocuments.delete(recordId); + if (this.#documents.get(addressId2)?.record.id === recordId) + this.#touchDocument(addressId2); + }; + } + async #runCommit(change, context, scope, withinMs, index) { + this.#assertHealthy(); + context.abortSignal?.throwIfAborted(); + const tx = new Transaction(this.#host, context, scope); + let result; + try { + result = await change(tx); + this.#assertHealthy(); + } catch (error) { + await tx.settleFailure(); + this.#sweepDocuments(); + throw error; + } + let writes; + try { + writes = await tx.settleSuccess(); + } catch (error) { + this.#sweepDocuments(); + throw error; + } + if (writes.length === 0) { + tx.discard(); + this.#sweepDocuments(); + return { value: result }; + } + if (this.#readOnly) { + tx.discard(); + this.#sweepDocuments(); + throw new ReadOnlySession("a commit with writes"); + } + const discard = () => tx.discard(); + if (withinMs !== void 0) { + const held = tx.holdable(); + if (held !== void 0) { + const part2 = this.#writer.admit({ + index, + writes: void 0, + keys: tx.writeKeys(), + context, + publish: () => this.#hold(tx, held, withinMs, context), + discard + }); + this.#sweepDocuments(); + return { value: result, after: part2.outcome }; + } + } + let membership = this.membershipKeys(tx); + if (!Array.isArray(membership)) { + try { + membership = await membership; + } catch (error) { + tx.discard(); + this.#sweepDocuments(); + throw error; + } + } + const taken = this.#holds.size === 0 ? NO_HOLDS : tx.takeHeld((id) => this.#holds.get(id)); + const keys = tx.writeKeys(); + for (const key of membership) + keys.push(key); + const part = this.#writer.admit({ + index, + writes, + keys, + context, + publish: (seq) => this.#adopt(tx, writes, taken, seq, context), + discard + }); + this.#sweepDocuments(); + return { value: result, after: part.outcome }; + } + /** + * Publish a stored commit (`Writer`), after the read caches took its writes: adopt it in memory and run its + * publication. A throw of the adoption leaves memory behind durable state; the writer fails the Session with it. + */ + #adopt(tx, writes, taken, seq, context) { + let documents; + try { + documents = tx.adopt(seq); + } catch (error) { + return { kind: "adoption", error }; + } + this.#indexTaskDocuments(tx, writes, documents, seq); + const failure = this.#publish(seq, writes, documents, context); + if (taken.length > 0) + this.#release(taken); + for (const document of documents.stored) + this.#touchDocument(addressId(document.record)); + return failure === void 0 ? void 0 : { kind: "listener", error: failure.error }; + } + /** + * At publication: keep `#taskDocuments` in step with a stored commit: the tasks it creates start with no documents, + * task documents it creates join their task, ones it retires leave, and tasks it ends leave the index. + */ + #indexTaskDocuments(tx, writes, documents, seq) { + for (const id of tx.createdTaskIds()) + this.#taskDocuments.set(id, /* @__PURE__ */ new Map()); + if (this.#taskDocuments.size === 0) + return; + for (const change of documents.stored) { + if (change.type !== "document" || change.record.scope.kind !== "task") + continue; + const known = this.#taskDocuments.get(change.record.scope.taskId); + if (known === void 0) + continue; + if (change.kind === "retire") + known.delete(change.record.id); + else if (change.record.createdAt === seq) + known.set(change.record.id, change.record); + } + for (const write of writes) { + if (write.type === "task" && write.value.state.status === "terminal") + this.#taskDocuments.delete(write.value.id); + } + } + /** + * Hold a deferred commit (`Transaction.holdable()`): adopt it in memory, queue its changes per document, pinned, by + * the earliest deadline of the document's held commits, and show it to the Session's own observers. + */ + #hold(tx, held, withinMs, context) { + let documents; + try { + documents = tx.adoptHeld(); + } catch (error) { + return { kind: "adoption", error }; + } + const deadline = this.#clock() + withinMs; + for (const { loaded, ops, base } of held) { + const id = loaded.record.id; + let hold = this.#holds.get(id); + if (hold === void 0) { + const unpin = this.#pinDocument(loaded.addressId, id); + hold = { loaded, pending: new PendingOps(), base: false, deadline, unpin }; + this.#holds.set(id, hold); + } + hold.pending.push(ops); + if (base) + hold.base = true; + if (deadline < hold.deadline) + hold.deadline = deadline; + } + this.#armHoldTimer(); + const publication = { kind: "memory", changes: documents }; + let failure; + for (const listener of [...this.#internalListeners]) { + try { + listener(publication, context); + } catch (error) { + failure ??= { kind: "listener", error }; + } + } + return failure; + } + /** + * Store the held changes `select` returns in one storage transaction, through the writer, from a job on the line. A + * barrier: they are selected once every commit admitted before is published. Memory already has them, so only + * `observeCommits()` observers see it, published as the writer publishes it. A failure fails the Session, as every + * Storage failure does. + */ + async #storeHolds(select, context) { + await this.#drained(); + const holds = select(); + if (holds.length === 0) + return; + const writes = []; + const changes = []; + const keys = []; + for (const hold of holds) { + const { record, tracker, valueVersion: version } = hold.loaded; + const value2 = tracker.value; + const stored = storeHeld(hold, NO_OPERATIONS, value2, version); + writes.push({ type: "document.change", id: record.id, content: stored.content }); + keys.push(documentKey(hold.loaded.addressId), incarnationKey(record.id)); + const conversationId = record.scope.kind === "conversation" ? record.scope.conversationId : void 0; + changes.push({ type: "document", kind: "change", record, conversationId, version, value: value2, ops: stored.ops }); + } + const publish2 = (seq) => { + for (const [index2, hold] of holds.entries()) { + if (writes[index2].content.kind === "base") + hold.loaded.deltasSinceBase = 0; + else + hold.loaded.deltasSinceBase++; + } + if (this.#commitListeners.size > 0) + this.#notifyHosts({ seq, changes }, context); + this.#release(holds); + return void 0; + }; + const index = this.#current.index; + await this.#writer.admit({ index, writes, keys, context, publish: publish2, discard: ignore }).outcome; + } + /** After their store: forget `holds`, unpin their documents, and move the timer to the next deadline. */ + #release(holds) { + for (const hold of holds) { + this.#holds.delete(hold.loaded.record.id); + hold.unpin(); + } + this.#armHoldTimer(); + } + /** + * Keep a foreground timer (Workers refuse background ones) for the earliest deadline of the held documents. When it + * fires, every held document is stored, in one storage transaction: a deadline is the latest time, and the others + * cost little more in the same transaction. Once fired, it stays until its job ran. + */ + #armHoldTimer() { + if (this.#closing !== void 0) + return; + let at = Infinity; + for (const hold of this.#holds.values()) + if (hold.deadline < at) + at = hold.deadline; + const armed = this.#holdTimer; + if (armed !== void 0 && (armed.fired || armed.at === at)) + return; + armed?.disarm(); + this.#holdTimer = void 0; + if (at === Infinity) + return; + const timer = { at, disarm: () => { + }, fired: false }; + const run2 = () => { + timer.fired = true; + this.#maintain(async () => { + if (this.#holdTimer === timer) + this.#holdTimer = void 0; + if (this.#closing !== void 0 || this.#failure !== void 0) + return; + await this.#storeHolds(() => [...this.#holds.values()], BACKGROUND_CONTEXT); + }).catch(() => { + }); + }; + this.#holdTimer = timer; + timer.disarm = this.#timers.foreground(run2, Math.max(0, at - this.#clock())); + } + /** + * At publication: publish a stored commit. Its telemetry events first, then the dispatch to `observeCommits()` + * observers, queued before the waits and aborts the observers wake, then the Session's own observers. Never throws: + * returns the first error of an own observer, which fails the Session once its storage call settled. + */ + #publish(seq, writes, documents, context) { + const changes = this.#commitListeners.size > 0 || this.#internalListeners.size > 0 ? [] : void 0; + for (const write of writes) { + switch (write.type) { + case "entry": + this.#event("pi.durable.entry", { "entry.id": write.value.id, "entry.kind": write.value.kind }, context); + changes?.push(write); + break; + case "submission": + if (write.value.status === "done" || write.value.status === "unanswered") { + this.#event("pi.durable.submission", { + "submission.id": write.value.id, + status: write.value.status, + "answer.entry_id": write.value.answer + }, context); + } + changes?.push(write); + break; + case "conversation": + case "task": + changes?.push(write); + } + } + if (changes === void 0) + return void 0; + const tables = changes.length; + for (const document of documents.observed) + changes.push(document); + const observed = { kind: "stored", seq, changes }; + if (this.#commitListeners.size > 0) { + const stored = documents.stored === documents.observed ? observed : { seq, changes: [...changes.slice(0, tables), ...documents.stored] }; + this.#notifyHosts(stored, context); + } + let failure; + for (const listener of [...this.#internalListeners]) { + try { + listener(observed, context); + } catch (error) { + failure ??= { error }; + } + } + return failure; + } + /** At publication: record a commit's telemetry event. A throwing adapter is reported, as publication must not throw. */ + #event(name, attributes, context) { + try { + context.telemetry.addEvent(name, attributes); + } catch (error) { + this.report(error, TELEMETRY, context); + } + } + /** + * At publication: call the `observeCommits()` observers with `publication` one microtask from now, and, queued + * before the Session's own observers run, before the waits they resolve and the aborts they queue. The observers are + * those attached when it runs; their guards report a throw. + */ + #notifyHosts(publication, context) { + void SETTLED.then(() => { + for (const observer of [...this.#commitListeners]) + observer.advance(publication, context); + }); + } + /** + * On the line, as one job: load the document at `resolved` and `attach` to it, so the attachment sees every commit + * after the value it starts from; `undefined` when absent. Cancelling `context` meanwhile rejects, after `cancel` + * undid an attachment made. + */ + async #attachDocument(definition, resolved, context, attach, cancel) { + const signal = context.abortSignal; + let cancelled = signal?.aborted ?? false; + const markCancelled = () => { + cancelled = true; + }; + signal?.addEventListener("abort", markCancelled, { once: true }); + try { + const attached = await this.#enqueue(async () => { + this.#assertHealthy(); + if (cancelled) + throw cancellationError(signal); + const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context); + if (cancelled) + throw cancellationError(signal); + return { value: loaded === void 0 ? void 0 : attach(loaded) }; + }); + if (attached === void 0) + return void 0; + if (cancelled) { + cancel(attached); + throw cancellationError(signal); + } + return attached; + } finally { + signal?.removeEventListener("abort", markCancelled); + } + } + /** + * Follow the address of the incarnation `loaded`, after checking the definition, with the observer `create` makes + * from its value, the idempotent detach, and the report of a listener error; returns that observer. It gets each + * committed change there, a retirement as a `retired` change with the last value it was given and no ops (a change + * in the retiring commit is not published), and a later incarnation as a root replacement. When the commit makes the + * owner task of a task document terminal, no later commit can change the address: after its changes the commit + * subscription and the pin go, and `ownerEnded` tells the observer, which stays subscribed to the Session's close + * until it ends (its detach drops that). `end` gets the Session's close. The current incarnation is pinned in the + * cache. The detach runs before `end`; once it ran, nothing more of the commit underway reaches the observer or pins + * anything for it. `create` and `ownerEnded` are module functions where they can be, so a watch keeps no closure of + * its caller. + */ + #observeDocument(definition, loaded, create, ownerEnded) { + checkRecordScope(definition, loaded.record); + checkRecordVersion(definition, loaded.record, loaded.storedVersion); + const addressId2 = loaded.addressId; + const address = loaded.record; + let incarnation = loaded.record.id; + let value2 = loaded.tracker.value; + let version = loaded.valueVersion; + let unpin = this.#pinDocument(addressId2, incarnation); + let unsubscribeCommit = () => { + }; + let unsubscribeClose = () => { + }; + let attached = true; + const detach = () => { + if (!attached) + return; + attached = false; + unsubscribeCommit(); + unsubscribeClose(); + unpin(); + }; + const observer = create(loaded.tracker.value, detach, (error) => this.report(error)); + unsubscribeCommit = this.followCommits((publication, context) => { + if (!attached) + return; + let ownerEnds = false; + for (const change of publication.changes) { + if (change.type === "task") { + const scope = address.scope; + if (scope.kind === "task" && change.value.id === scope.taskId && change.value.state.status === "terminal") { + ownerEnds = true; + } + continue; + } + if (change.type !== "document") + continue; + if (change.record.id !== incarnation && !atAddress(change.record, address)) + continue; + if (change.kind === "retire") { + if (change.record.id !== incarnation) + continue; + const last = value2; + incarnation = void 0; + value2 = void 0; + unpin(); + observer.advance({ kind: "value", value: last, ops: NO_OPERATIONS, retired: true }, context); + } else if (change.record.id !== incarnation || change.version !== version) { + if (change.record.id !== incarnation) { + incarnation = change.record.id; + unpin(); + unpin = this.#pinDocument(addressId2, incarnation); + } + version = change.version; + value2 = change.value; + observer.advance({ kind: "value", value: value2, ops: [["r", value2]], retired: false }, context); + } else { + if (change.ops.length === 0) + continue; + value2 = change.value; + observer.advance({ kind: "value", value: value2, ops: change.ops, retired: false }, context); + } + if (!attached) + return; + } + if (!ownerEnds) + return; + unsubscribeCommit(); + unpin(); + ownerEnded(observer); + }); + unsubscribeClose = this.subscribeClose(() => { + detach(); + observer.end(closeEndOf(observer, closeEnds(this.#failure))); + }); + return observer; + } + async #loadDocument(definition, addressId2, address, context) { + const gate = this.touch(documentKey(addressId2)); + if (gate !== void 0) + await gate; + const cached = this.#documents.get(addressId2); + if (cached?.valueVersion === definition.version) { + this.#touchDocument(addressId2); + return cached; + } + if (cached !== void 0) { + const id = cached.record.id; + await this.#storeHolds(() => { + const hold = this.#holds.get(id); + return hold === void 0 ? NO_HOLDS : [hold]; + }, context); + this.#dropDocument(addressId2); + } + const record = await this.#storage.findDocument(address, "current", context); + if (record === void 0) + return void 0; + const stored = await this.#storage.document(record.id, "current", context); + if (stored === void 0) + throw new Error(`Current document ${record.id} (${record.kind}) cannot be read`); + const value2 = materializeDocument(definition, stored); + const loaded = { + addressId: addressId2, + record: stored.record, + storedVersion: stored.version, + valueVersion: definition.version, + deltasSinceBase: stored.deltasSinceBase, + tracker: track(value2) + }; + this.#documents.set(addressId2, loaded); + this.#touchDocument(addressId2); + return loaded; + } + /** + * Run `job` on the line, after the jobs before it, and deliver its outcome in line order: once the jobs before it + * have theirs, and once its commit, if it admitted one, is published. With `exempt`, a job that admitted no commit and + * waited at no gate is delivered as soon as it has its outcome: it is equivalent to having run before every + * unpublished commit. A job + * whose outcome comes after the one a failure is gets `SessionFailed` instead. At most `MAX_OUTSTANDING` jobs wait + * for their delivery; beyond it the line waits. + */ + #enqueue(work, exempt = false) { + const job = { index: this.#nextIndex++, hit: false }; + const ran = this.#tail.then(async () => { + while (this.#outstanding >= MAX_OUTSTANDING) { + this.#room ??= Promise.withResolvers(); + await this.#room.promise; + } + this.#outstanding++; + return this.#onLine(job, work); + }); + this.#tail = ran.then(ignore, ignore); + const outcome = ran.then((result) => { + if (result.after === void 0) { + this.#ended(job); + return result.value; + } + return result.after.then(() => { + this.#ended(job); + return result.value; + }, (error) => { + this.#ended(job); + throw error; + }); + }, (error) => { + this.#ended(job); + throw error; + }); + outcome.catch(ignore); + const fence = this.#fence; + this.#fence = fence.then(() => outcome).then(ignore, ignore); + const index = job.index; + const deliver = (fenced) => (fenced ? fence.then(() => outcome) : outcome).then((value2) => { + this.#delivered(index, fenced); + if (this.#poisoned(index)) + throw new SessionFailed(this.#failure.error); + return value2; + }, (error) => { + this.#delivered(index, fenced); + throw this.#poisoned(index) ? new SessionFailed(this.#failure.error) : error; + }); + return exempt ? ran.then((result) => deliver(job.hit || result.after !== void 0), () => deliver(job.hit)) : deliver(true); + } + /** Whether job `index` comes after the failure's boundary without an outcome of its own: it gets `SessionFailed`. */ + #poisoned(index) { + return this.#failAt !== void 0 && index > this.#failAt && !this.#ownOutcomes.has(index); + } + /** The job's outcome settled: run what waited for it (`onJobEnd()`). */ + #ended(job) { + const ends = job.ends; + if (ends === void 0) + return; + job.ends = void 0; + for (const end of ends) + end(); + } + /** Run a job of the Session's own upkeep on the line: no outcome to deliver, so no fence and no `SessionFailed`. */ + #maintain(work) { + const job = { index: this.#nextIndex++, hit: false }; + const ran = this.#tail.then(() => this.#onLine(job, work)); + this.#tail = ran.then(ignore, ignore); + return ran; + } + /** Hold the line for job `index` until its line part, through admission, is done. Gates are its touches' own. */ + async #onLine(job, work) { + this.#current = job; + try { + return await work(job.index); + } finally { + this.#current = void 0; + } + } + /** A `*` barrier: once every part admitted so far is published, or never will be. */ + async #drained() { + const drained = this.#writer.drained(); + if (drained !== void 0) + await drained; + } + /** + * Run one commit's publication inside the Storage's own scope (`Storage.publishing`), such as a store host's batch; + * directly without one. `undefined` when it ran at once, else settles once it ran. A Storage that throws instead of + * running it is reported, and the publication runs directly: the commit is stored, so it must be published. + */ + #publishing(publish2, context) { + const publishing = this.#storage.publishing; + if (publishing === void 0) { + publish2(); + return void 0; + } + const done = Promise.withResolvers(); + let ran = false; + const once = () => { + if (ran) + return; + ran = true; + try { + publish2(); + } finally { + done.resolve(); + } + }; + try { + publishing(once, context); + } catch (error) { + this.report(error, LISTENER, context); + once(); + } + return done.promise; + } + /** Job `index` has its outcome: let the line go on past `MAX_OUTSTANDING`. */ + #delivered(index, fenced) { + if (fenced) + this.#lastDelivered = index; + this.#outstanding--; + const room = this.#room; + if (room !== void 0 && this.#outstanding < MAX_OUTSTANDING) { + this.#room = void 0; + room.resolve(); + } + } + /** Internal: throw `SessionFailed` when failed, else an error when closing. */ + assertUsable() { + this.#assertHealthy(); + if (this.#closing !== void 0) + throw new Error("Session is closed"); + } + #assertHealthy() { + if (this.#failure !== void 0) + throw new SessionFailed(this.#failure.error); + } +}; +function contained(callback, onError) { + try { + const result = callback(); + if (typeof result?.then === "function") + Promise.resolve(result).catch(onError); + } catch (error) { + onError(error); + } +} +function guardObserver(observer, detach, report2) { + let attached = { observer, detach, report: report2 }; + const release = () => { + const released = attached; + attached = void 0; + released?.detach(); + return released; + }; + const fault = (current, error, context) => { + current.report(error, context); + if (release() !== void 0) { + const end = { kind: "listener_error", error: toError2(error) }; + contained(() => current.observer.end(end), current.report); + } + }; + return { + advance: (call, context) => { + const current = attached; + if (current === void 0) + return; + try { + const result = current.observer.advance(call, context); + if (typeof result?.then === "function") { + Promise.resolve(result).catch((error) => fault(current, error, context)); + } + } catch (error) { + fault(current, error, context); + } + }, + end: (end) => { + const ending = release(); + if (ending !== void 0) + contained(() => ending.observer.end(end), ending.report); + }, + detach: () => { + release(); + } + }; +} +function toError2(error) { + if (error instanceof Error) + return error; + try { + return new Error(String(error)); + } catch { + return new Error(Object.prototype.toString.call(error)); + } +} +var ENDED2 = { kind: "ended" }; +function closeEnds(failure) { + if (failure !== void 0) { + const failed = { kind: "failed", error: failure.error }; + return { watch: failed, observer: failed }; + } + return { watch: ENDED2, observer: { kind: "failed", error: new Error("session closed") } }; +} +function closeEndOf(follower, ends) { + return follower instanceof Watch ? ends.watch : ends.observer; +} +function observation(value2, guarded) { + return { value: value2, detach: () => guarded.detach() }; +} +function createWatch(value2, detach, report2) { + return new Watch(value2, detach, report2); +} +function abortWatch(watch) { + watch.abort(); +} +function detachObservation(attached) { + attached.detach(); +} +function endWatch(watch) { + watch.endAfterDelivery(); +} +function endGuarded(guarded) { + guarded.end(ENDED2); +} +function atAddress(record, address) { + if (record.kind !== address.kind || record.key !== address.key) + return false; + switch (record.scope.kind) { + case "session": + return address.scope.kind === "session"; + case "conversation": + return address.scope.kind === "conversation" && address.scope.conversationId === record.scope.conversationId; + case "task": + return address.scope.kind === "task" && address.scope.taskId === record.scope.taskId; + } +} +function cancellationError(signal) { + return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); +} +var GuardedStorage = class { + #storage; + #latch; + /** Backend calls underway, also those off the Session line, which close waits for before it closes the backend. */ + #underway = /* @__PURE__ */ new Set(); + #closing = false; + constructor(storage, latch) { + this.#storage = storage; + this.#latch = latch; + } + /** The backend's own scope for a publication (`Storage.publishing`), when it has one; not a call to guard. */ + get publishing() { + return scopeOf(this.#storage); + } + /** + * Guarded like every call, but its outcome is the Session writer's to judge: it publishes the batches received first, + * then fails the Session for a refusal or a rejection, and for a Session that failed meanwhile. + */ + async commit(batches, receive, context) { + this.#assertHealthy(); + if (this.#closing) + throw new Error("Session is closed"); + const call = (async () => this.#storage.commit(batches, receive, context))(); + this.#underway.add(call); + try { + return await call; + } finally { + this.#underway.delete(call); + } + } + mintId() { + return this.#call(void 0, () => this.#storage.mintId()); + } + conversation(id, context) { + return this.#call(context, () => this.#storage.conversation(id, context)); + } + scanConversations(query, limit, cursor, context) { + return this.#call(context, () => this.#storage.scanConversations(query, limit, cursor, context)); + } + entry(first, second, third) { + if (third === void 0) { + const context = second; + return this.#call(context, () => this.#storage.entry(first, context)); + } + return this.#call(third, () => this.#storage.entry(first, second, third)); + } + findLatestHeadMarker(conversationId, atOrBeforeEntryId, context) { + return this.#call(context, () => this.#storage.findLatestHeadMarker(conversationId, atOrBeforeEntryId, context)); + } + scanEntries(query, limit, cursor, context) { + return this.#call(context, () => this.#storage.scanEntries(query, limit, cursor, context)); + } + task(id, context) { + return this.#call(context, () => this.#storage.task(id, context)); + } + scanTasks(query, limit, cursor, context) { + return this.#call(context, () => this.#storage.scanTasks(query, limit, cursor, context)); + } + submission(id, context) { + return this.#call(context, () => this.#storage.submission(id, context)); + } + scanSubmissions(query, limit, cursor, context) { + return this.#call(context, () => this.#storage.scanSubmissions(query, limit, cursor, context)); + } + submissionByRequest(conversationId, requestId, context) { + return this.#call(context, () => this.#storage.submissionByRequest(conversationId, requestId, context)); + } + findDocument(address, at, context) { + return this.#call(context, () => this.#storage.findDocument(address, at, context)); + } + document(id, at, context) { + return this.#call(context, () => this.#storage.document(id, at, context)); + } + scanDocuments(query, limit, cursor, context) { + return this.#call(context, () => this.#storage.scanDocuments(query, limit, cursor, context)); + } + /** + * Close the backend once every call underway has settled, so none outlives it or fails after `closed` settles. New + * calls are refused from here on: a paged read must not start its next page while the backend closes. + */ + async close(context) { + this.#closing = true; + await Promise.allSettled([...this.#underway]); + return this.#storage.close(context); + } + /** + * Run one backend call under the guard. `read` is a read's context; `commit()` and `mintId()` pass none, so nothing + * exempts their errors: once a batch is admitted, whether it committed is unknown. A call still underway when another + * fails the Session ends with `SessionFailed` too, whatever it returns: nothing it read or wrote is used. + */ + async #call(read2, run2) { + this.#assertHealthy(); + if (this.#closing) + throw new Error("Session is closed"); + let result; + const call = (async () => run2())(); + this.#underway.add(call); + try { + result = await call; + } catch (error) { + this.#assertHealthy(); + const exempt = read2 !== void 0 && (error instanceof StorageRequestError || read2.abortSignal?.aborted === true); + if (!exempt) + this.#latch.fail(error); + throw error; + } finally { + this.#underway.delete(call); + } + this.#assertHealthy(); + return result; + } + #assertHealthy() { + const failure = this.#latch.failure; + if (failure !== void 0) + throw new SessionFailed(failure.error); + } +}; + +// node_modules/@earendil-works/pi-durable/dist/harness/agent.js +var DEFAULT_RETRY_POLICY = { + enabled: true, + maxRetries: 3, + baseDelayMs: 2e3, + maxAgentDelayMs: 6e4 +}; +var DEFAULT_COMPACTION_POLICY = { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 2e4, + backgroundTokens: 32768 +}; +var DEFAULT_PROGRESS_POLICY = { + partialIntervalMs: 100, + outputIntervalMs: 100, + persistWithinMs: 1e3 +}; +var INSTRUCTIONS_KEY = "instructions"; +var AgentDoc = defineDoc({ + kind: "pi.agent", + version: 1, + scope: "conversation", + history: "rewindable", + fork: "asOf", + initial: () => ({}), + checkpointWhen: () => true +}); +var DEFAULT_CONTEXT_RETENTION_MS = 6e5; +var DEFAULT_RESTART_LIMIT = 5; +function restartLimit(limit) { + return typeof limit === "number" && limit >= 1 ? Math.floor(limit) : DEFAULT_RESTART_LIMIT; +} +function resolveSettings(settings) { + const extensions = settings?.extensions; + return { + ...extensions === void 0 ? {} : { extensions }, + stream: { ...settings?.stream }, + retry: { ...DEFAULT_RETRY_POLICY, ...settings?.retry }, + compaction: { ...DEFAULT_COMPACTION_POLICY, ...settings?.compaction }, + // Field by field, so an explicitly undefined interval keeps its default. + progress: { + partialIntervalMs: settings?.progress?.partialIntervalMs ?? DEFAULT_PROGRESS_POLICY.partialIntervalMs, + outputIntervalMs: settings?.progress?.outputIntervalMs ?? DEFAULT_PROGRESS_POLICY.outputIntervalMs, + persistWithinMs: persistWithin(settings?.progress?.persistWithinMs) + }, + toolExecution: settings?.toolExecution ?? "parallel", + steeringMode: settings?.steeringMode ?? "one-at-a-time", + followUpMode: settings?.followUpMode ?? "one-at-a-time", + contextRetentionMs: settings?.contextRetentionMs ?? DEFAULT_CONTEXT_RETENTION_MS, + documentRetentionMs: documentRetention(settings?.documentRetentionMs), + afterFailedRun: settings?.afterFailedRun ?? "keep", + restartLimit: restartLimit(settings?.restartLimit) + }; +} +async function configure(tx, conversationId, change) { + const state = await tx.doc(AgentDoc, conversationId); + applyChange(state, change); +} +async function addTools(tx, conversationId, added) { + const state = await tx.doc(AgentDoc, conversationId); + const tools = state.tools; + if (Array.isArray(tools)) { + for (const name of added) + if (!tools.includes(name)) + tools.push(name); + } else if (tools?.remove.some((name) => added.includes(name))) { + state.tools = { remove: tools.remove.filter((name) => !added.includes(name)) }; + } + const modelTools = state.modelTools; + if (Array.isArray(modelTools)) { + for (const name of added) + if (!modelTools.includes(name)) + modelTools.push(name); + return; + } + const remove = (modelTools?.remove ?? []).filter((name) => !added.includes(name)); + const add = [...modelTools?.add ?? []]; + for (const name of added) + if (!add.includes(name)) + add.push(name); + state.modelTools = { ...remove.length === 0 ? {} : { remove }, add }; +} +function applyChange(state, change) { + const set = (key, value2) => { + if (value2 === void 0) + return; + if (value2 === null) + delete state[key]; + else + state[key] = value2; + }; + set("model", change.model === void 0 || change.model === null ? change.model : { ...change.model }); + set("thinkingLevel", change.thinkingLevel); + const extensions = change.extensions; + set("extensions", extensions === void 0 || extensions === null ? extensions : isList(extensions) ? names(extensions) : { + ...extensions.add === void 0 ? {} : { add: names(extensions.add) }, + ...extensions.remove === void 0 ? {} : { remove: names(extensions.remove) } + }); + const tools = change.tools; + set("tools", tools === void 0 || tools === null ? tools : isList(tools) ? names(tools) : { remove: names(tools.remove) }); + const modelTools = change.modelTools; + set("modelTools", modelTools === void 0 || modelTools === null ? modelTools : isList(modelTools) ? names(modelTools) : { + ...modelTools.remove === void 0 ? {} : { remove: names(modelTools.remove) }, + ...modelTools.add === void 0 ? {} : { add: names(modelTools.add) } + }); + set("instructions", change.instructions); + set("cwd", change.cwd); + const maxTurns = change.maxTurns; + if (maxTurns !== void 0 && maxTurns !== null && !(Number.isSafeInteger(maxTurns) && maxTurns >= 1)) { + throw new RangeError(`maxTurns must be an integer 1 or more, got ${String(maxTurns)}`); + } + set("maxTurns", maxTurns); +} +function isList(value2) { + return Array.isArray(value2); +} +function names(items) { + return items.map((item) => item.name); +} +async function createAgent(tx, conversation) { + if (conversation.parent !== void 0) + return; + const agent = await tx.doc(AgentDoc, conversation.id); + if (conversation.owner === void 0) + return; + const owner = await tx.doc(AgentDoc, conversation.owner.conversationId); + Object.assign(agent, copyJson(owner)); +} +function agentHooks(agent, taskName) { + const handlers = []; + for (const extension of agent.extensions) { + for (const hook2 of extension.hooks ?? []) { + if (hook2.task === taskName) + handlers.push({ extension: extension.name, handlers: hook2.handlers }); + } + } + return handlers; +} +function sectionExtension(agent, key) { + let found; + for (const extension of agent.extensions) { + const provides = extension.sections?.some((section2) => section2.key === key) === true; + const wraps = extension.wraps?.some((wrap) => "section" in wrap && wrap.section === key) === true; + if (provides || wraps) + found = extension.name; + } + return found; +} +function toolExtension(agent, name) { + let found; + for (const extension of agent.extensions) { + const provides = extension.tools?.some((tool) => tool.name === name) === true; + const wraps = extension.wraps?.some((wrap) => "tool" in wrap && wrap.tool === name) === true; + if (provides || wraps) + found = extension.name; + } + return found; +} +function resolveAgent(state, snapshot, settings, report2) { + const extensions = selectExtensions(state?.extensions, snapshot, settings); + const composed = /* @__PURE__ */ new Map(); + for (const extension of extensions) + for (const tool of extension.tools ?? []) + composed.set(tool.name, tool); + const sections = /* @__PURE__ */ new Map(); + for (const extension of extensions) { + for (const section2 of extension.sections ?? []) + sections.set(section2.key, section2); + } + for (const extension of extensions) { + for (const wrap of extension.wraps ?? []) { + const wrapReport = (error) => report2(error, extension.name); + if ("tool" in wrap) + applyWrap(composed, wrap.tool, (tool) => wrap.wrap(tool), (tool) => tool.name, wrapReport); + else + applyWrap(sections, wrap.section, (section2) => wrap.wrap(section2), (section2) => section2.key, wrapReport); + } + } + const enabled = filterTools([...composed.values()], state?.tools); + const callableBy = (caller) => (tool) => tool.callers === void 0 || tool.callers.includes(caller); + const modelCallable = enabled.filter(callableBy("model")); + const tools = offeredTools(modelCallable, state?.modelTools); + const offered = new Set(tools); + const deferred = modelCallable.filter((tool) => tool.deferred === true && !offered.has(tool)); + const callable = enabled.filter(callableBy("tools")); + const instructions = state?.instructions; + const agentSections = [...sections.values()]; + if (instructions !== void 0) + agentSections.push({ key: INSTRUCTIONS_KEY, render: () => instructions }); + const agent = { + ...state?.model === void 0 ? {} : { model: state.model }, + thinkingLevel: state?.thinkingLevel ?? "off", + extensions, + tools, + callable, + deferred, + sections: agentSections, + ...instructions === void 0 ? {} : { instructions }, + ...state?.cwd === void 0 ? {} : { cwd: state.cwd }, + ...state?.maxTurns === void 0 ? {} : { maxTurns: state.maxTurns } + }; + return agent; +} +function offeredTools(tools, filter) { + if (Array.isArray(filter)) + return filterTools(tools, filter); + const removed = new Set(filter?.remove ?? []); + const kept = tools.filter((tool) => tool.deferred !== true && !removed.has(tool.name)); + const byName = new Map(tools.map((tool) => [tool.name, tool])); + const loaded = [...new Set(filter?.add ?? [])].flatMap((name) => { + const tool = byName.get(name); + return tool?.deferred === true && !removed.has(name) ? [tool] : []; + }); + return [...kept, ...loaded]; +} +function filterTools(tools, filter) { + if (filter === void 0) + return tools; + if (Array.isArray(filter)) { + const byName = new Map(tools.map((tool) => [tool.name, tool])); + return [...new Set(filter)].flatMap((name) => { + const tool = byName.get(name); + return tool === void 0 ? [] : [tool]; + }); + } + const removed = new Set(filter.remove); + return tools.filter((tool) => !removed.has(tool.name)); +} +function selectExtensions(stored, snapshot, settings) { + let selected; + if (Array.isArray(stored)) + selected = stored; + else { + const base = settings.extensions?.map((extension) => extension.name) ?? snapshot.installed().map((e) => e.name); + const edit = stored; + const removed = new Set(edit?.remove ?? []); + selected = [...base, ...edit?.add ?? []].filter((name) => !removed.has(name)); + } + const extensions = []; + for (const name of new Set(selected)) { + const extension = snapshot.extension(name); + if (extension !== void 0) + extensions.push(extension); + } + return extensions; +} +function applyWrap(items, target, wrap, nameOf, report2) { + const item = items.get(target); + if (item === void 0) + return; + try { + const wrapped = wrap(item); + if (nameOf(wrapped) !== target) + throw new Error(`Wrapper renamed ${target} to ${nameOf(wrapped)}`); + items.set(target, wrapped); + } catch (error) { + items.delete(target); + report2(error); + } +} + +// node_modules/@earendil-works/pi-ai/dist/utils/estimate.js +var CHARS_PER_TOKEN = 3.5; +var ESTIMATED_IMAGE_CHARS = 4800; +function calculateContextTokens(usage) { + return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; +} +function safeJsonStringify(value2) { + try { + return JSON.stringify(value2) ?? "undefined"; + } catch { + return "[unserializable]"; + } +} +function estimateTextAndImageContentChars(content) { + if (typeof content === "string") + return content.length; + let chars = 0; + for (const block of content) + chars += block.type === "text" ? block.text.length : ESTIMATED_IMAGE_CHARS; + return chars; +} +function estimateTextTokens(text) { + return Math.ceil(text.length / CHARS_PER_TOKEN); +} +function estimateTextAndImageContentTokens(content) { + return Math.ceil(estimateTextAndImageContentChars(content) / CHARS_PER_TOKEN); +} +function estimateMessageTokens(message) { + let chars = 0; + if (message.role === "system") { + return estimateTextTokens(getSystemMessageText(message)) + estimateToolsTokens(message.toolsAdded) + estimateToolsTokens(message.toolsRemoved); + } + if (message.role === "user") + return estimateTextAndImageContentTokens(message.content); + if (message.role === "toolResult") + return estimateTextAndImageContentTokens(message.content); + for (const block of message.content) { + if (block.type === "text") { + chars += block.text.length; + } else if (block.type === "thinking") { + chars += block.thinking.length; + } else { + chars += block.name.length + safeJsonStringify(block.arguments).length; + } + } + return Math.ceil(chars / CHARS_PER_TOKEN); +} +function estimateToolsTokens(tools) { + if (!tools || tools.length === 0) + return 0; + return estimateTextTokens(safeJsonStringify(tools)); +} + +// node_modules/@earendil-works/pi-ai/dist/utils/retry.js +function buildProviderErrorPattern(patterns) { + return new RegExp(patterns.join("|"), "i"); +} +var NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([ + // OpenCode Go/free-tier limits returned as 429 JSON error types by OpenCode's + // Zen API. These are subscription/account limits, not transient throttles. + "GoUsageLimitError", + "FreeUsageLimitError", + // OpenCode Go subscription-limit text asks users to enable available-balance + // usage after rolling/weekly/monthly limits are reached. + "Monthly usage limit reached", + "available balance", + // Generic quota/budget/billing exhaustion. `insufficient_quota` is OpenAI's + // quota/billing error code; the other strings cover common gateway wording. + "insufficient_quota", + "out of budget", + "quota exceeded", + "billing", + // Sign in with ChatGPT: the subscription's shared usage limit, which resets + // after hours rather than seconds. + "subscription_sharing_usage_limit_exceeded", + // Sign in with ChatGPT on a ChatGPT Business plan: a usage limit set by the + // workspace admin. Only the admin can raise it. + "subscription_sharing_workplace_individual_usage_limit_exceeded", + // Zhipu/Z.ai (open.bigmodel.cn) error code 1308: the plan's usage limit is + // reached, sent as HTTP 429. It resets after hours, not seconds (#11537). + '"code"\\s*:\\s*"?1308(?![0-9])' +]); +var RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([ + // Generic provider load, HTTP status, and server-side transient failures. + "overloaded", + "server_busy", + "servers are currently busy", + "currently experiencing high demand", + "model is at capacity", + "rate.?limit", + "too many requests", + // Status codes must not be part of a longer word or number, e.g. a request id or byte count (#11057). + "(? captureContextBounds(session, line, conversationId, context, at, visible)); + if (bounds === void 0) + return { view: emptyView(), range: void 0 }; + freezeJson(bounds.head); + const current = at === void 0; + let range; + if (previous === void 0 || previous.bounds.head?.id !== bounds.head?.id) { + const entries2 = freezeJson(await scanRange(storage, conversationId, rangeQuery(bounds), context)); + range = deriveRange(bounds, entries2, current); + } else if (bounds.tail === previous.bounds.tail) { + range = previous; + } else if (bounds.tail < previous.bounds.tail) { + range = deriveRange(bounds, previous.entries.filter((entry2) => entry2.id <= bounds.tail), false); + } else { + const minEntryId = idFromNumber(previous.bounds.tail + 1); + const added = await scanRange(storage, conversationId, { minEntryId, maxEntryId: bounds.tail }, context); + range = extendRange(previous, bounds, freezeJson(added), current); + } + if (range.current && session.knownLatestEntryId(conversationId) !== range.bounds.tail) { + range = { ...range, current: false }; + } + const { view: view2 } = range; + return { + view: { + ...view2, + entries: [...view2.entries], + contributions: [...view2.contributions], + messages: [...view2.messages] + }, + range + }; +} +async function readContext(session, storage, conversationId, context, at) { + const bounds = await session.readOnLine((line) => captureContextBounds(session, line, conversationId, context, at)); + return deriveContext(storage, conversationId, bounds, context); +} +async function deriveContext(storage, conversationId, bounds, context) { + if (bounds === void 0) + return emptyView(); + return deriveRange(bounds, await scanRange(storage, conversationId, rangeQuery(bounds), context), false).view; +} +function emptyView() { + return { head: void 0, entries: [], contributions: [], messages: [] }; +} +function rangeEntry(range, id) { + const entries2 = range.entries; + let low = 0; + let high = entries2.length - 1; + while (low <= high) { + const middle = low + high >>> 1; + const entry2 = entries2[middle]; + if (entry2.id === id) + return entry2; + if (entry2.id < id) + low = middle + 1; + else + high = middle - 1; + } + return void 0; +} +function advanceRange(range, appended) { + if (!range.current || appended.length === 0) + return range; + const added = freezeJson(appended); + let head = range.bounds.head; + for (const entry2 of added) + if (entry2.head !== void 0) + head = entry2; + const bounds = { head, tail: added.at(-1).id }; + if (head?.id === range.bounds.head?.id) + return extendRange(range, bounds, added, true); + const start = head.head; + if (range.bounds.head !== void 0 && start < range.bounds.head.head) + return void 0; + const entries2 = [...range.entries, ...added].filter((entry2) => entry2.id >= start); + return deriveRange(bounds, entries2, true); +} +function deriveRange(bounds, entries2, current) { + const edits = /* @__PURE__ */ new Map(); + for (const entry2 of entries2) + for (const edit of entry2.edits ?? []) + edits.set(edit.target, edit); + const active = selectActive(bounds.head, entries2); + const contributions = active.map((entry2) => contribute(entry2, edits.get(entry2.id))); + const { settled, open } = settle([], contributions.flat()); + const baseline = findBaseline(bounds.head, active); + const messages = leadWithSystem([...settled, ...orderToolResults(open)], baseline); + const view2 = { head: bounds.head, entries: active, contributions, messages }; + return { bounds, entries: entries2, view: view2, edited: new Set(edits.keys()), settled, open, baseline, current }; +} +function extendRange(previous, bounds, added, current) { + const entries2 = [...previous.entries, ...added]; + if (added.some((entry2) => entry2.edits !== void 0 || entry2.head !== void 0 || previous.edited.has(entry2.id))) { + return deriveRange(bounds, entries2, current); + } + const contributions = added.map((entry2) => contribute(entry2, void 0)); + const { settled, open } = settle(previous.settled, [...previous.open, ...contributions.flat()]); + const baseline = previous.baseline !== void 0 ? previous.baseline : findBaseline(bounds.head, added); + const view2 = { + head: bounds.head, + entries: [...previous.view.entries, ...added], + contributions: [...previous.view.contributions, ...contributions], + messages: leadWithSystem([...settled, ...orderToolResults(open)], baseline) + }; + return { bounds, entries: entries2, view: view2, edited: previous.edited, settled, open, baseline, current }; +} +function findBaseline(head, entries2) { + for (const entry2 of entries2) { + if (head !== void 0 && entry2.id <= head.id) + continue; + if (SystemEntry.is(entry2)) + return entry2.model?.find((message) => message.role === "system") ?? null; + if (head === void 0 && carriesSystemMessage(entry2)) + return null; + } + return void 0; +} +function carriesSystemMessage(entry2) { + const isSystem = (message) => message.role === "system"; + return (entry2.model?.some(isSystem) ?? false) || (entry2.edits?.some((edit) => edit.action === "replace" && edit.messages.some(isSystem)) ?? false); +} +function leadWithSystem(messages, baseline) { + const index = messages.findIndex((message) => message.role === "system"); + if (index <= 0) + return messages; + const system = messages[index]; + if (system !== baseline && messages.slice(0, index).some((message) => message.role !== "user")) { + return messages; + } + return [system, ...messages.slice(0, index), ...messages.slice(index + 1)]; +} +function contribute(entry2, edit) { + if (edit?.action === "omit") + return Object.freeze([]); + const contributed = edit?.action === "replace" ? edit.messages : entry2.model ?? []; + return Object.freeze(contributed.filter((message) => message.role !== "assistant" || !EXCLUDED_STOP_REASONS.has(message.stopReason))); +} +var deeplyFrozen = /* @__PURE__ */ new WeakSet(); +function freezeJson(value2) { + if (value2 === null || typeof value2 !== "object" || deeplyFrozen.has(value2)) + return value2; + for (const child of Object.values(value2)) + freezeJson(child); + Object.freeze(value2); + deeplyFrozen.add(value2); + return value2; +} +function settle(settled, open) { + const last = open.findLastIndex((message) => message.role === "assistant"); + if (last <= 0) + return { settled, open }; + return { settled: [...settled, ...orderToolResults(open.slice(0, last))], open: open.slice(last) }; +} +function rangeQuery(bounds) { + const head = bounds.head; + return head === void 0 ? { maxEntryId: bounds.tail } : { minEntryId: head.head, maxEntryId: bounds.tail }; +} +async function scanRange(storage, conversationId, range, context) { + const entries2 = []; + let cursor; + do { + const page2 = await storage.scanEntries({ conversationId, ...range }, SCAN_PAGE_SIZE2, cursor, context); + entries2.push(...page2.items); + cursor = page2.next; + } while (cursor !== void 0); + return entries2.reverse(); +} +function selectActive(head, range) { + return head === void 0 ? range : [head, ...range.filter((entry2) => entry2.head === void 0)]; +} +function orderToolResults(messages) { + const ordered = []; + for (let index = 0; index < messages.length; index++) { + const message = messages[index]; + if (message.role === "toolResult") + continue; + ordered.push(message); + if (message.role !== "assistant") + continue; + const calls = message.content.filter((content) => content.type === "toolCall"); + if (calls.length === 0) + continue; + const results = /* @__PURE__ */ new Map(); + for (let next = index + 1; next < messages.length && messages[next].role !== "assistant"; next++) { + const candidate = messages[next]; + if (candidate.role === "toolResult" && !results.has(candidate.toolCallId)) { + results.set(candidate.toolCallId, next); + } + } + for (const call of calls) { + const resultIndex = results.get(call.id); + ordered.push(resultIndex === void 0 ? missingResult(call, message.timestamp) : messages[resultIndex]); + } + } + return ordered; +} +function missingResult(call, timestamp) { + return freezeJson({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content: [{ type: "text", text: MISSING_RESULT_TEXT }], + isError: true, + details: { reason: "missing_result" }, + timestamp + }); +} + +// node_modules/@earendil-works/pi-ai/dist/utils/overflow.js +var OVERFLOW_PATTERNS = [ + /prompt (?:is )?too long/i, + // Anthropic and z.ai token overflow + /prompt exceeds max length/i, + // z.ai CN endpoint token overflow + /request_too_large/i, + // Anthropic request byte-size overflow (HTTP 413) + /input is too long for requested model/i, + // Amazon Bedrock + /exceeds the context window/i, + // OpenAI (Completions & Responses API) + /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, + // OpenAI-compatible proxies (LiteLLM) + /context window exceeded/i, + // LiteLLM pre-call check (ContextWindowExceededError) + /input token count.*exceeds the maximum/i, + // Google (Gemini) + /maximum prompt length is \d+/i, + // xAI (Grok) + /reduce the length of the messages/i, + // Groq + /maximum context length is \d+ tokens/i, + // OpenRouter (most backends) + /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, + // OpenRouter/Poolside + /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, + // Together AI + /exceeds the limit of \d+/i, + // GitHub Copilot + /exceeds the available context size/i, + // llama.cpp server + /greater than the context length/i, + // LM Studio + /context window exceeds limit/i, + // MiniMax + /exceeded model token limit/i, + // Kimi For Coding + /too large for model with \d+ maximum context length/i, + // Mistral + /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, + // DS4 server + /model_context_window_exceeded/i, + // z.ai non-standard finish_reason surfaced as error text + /prompt too long; exceeded (?:max )?context length/i, + // Ollama explicit overflow error + /range of input length should be/i, + // DashScope / Qwen Token Plan + /context[_ ]length[_ ]exceeded/i, + // Generic fallback + /too many tokens/i, + // Generic fallback + /token limit exceeded/i, + // Generic fallback + // HTTP 413 means request-size overflow regardless of provider or body text. + // Match raw SDK errors and formatProviderError's prefix, not a status mentioned in the body. + /^(?:[\w -]+ API error \(413\):|413(?:\s|:|$))/i +]; +var CEREBRAS_BODYLESS_OVERFLOW_PATTERN = /^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i; +var NON_OVERFLOW_PATTERNS = [ + /^(Throttling error|Service unavailable):/i, + // AWS Bedrock non-overflow errors (human-readable prefixes from formatBedrockError) + /rate limit/i, + // Generic rate limiting + /too many requests/i + // Generic HTTP 429 style +]; +function isContextOverflow(message, contextWindow) { + if (message.stopReason === "error" && message.errorMessage) { + const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage)); + if (!isNonOverflow) { + if (OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage))) { + return true; + } + if (message.provider === "cerebras" && CEREBRAS_BODYLESS_OVERFLOW_PATTERN.test(message.errorMessage)) { + return true; + } + } + } + if (contextWindow && message.stopReason === "stop") { + const inputTokens = message.usage.input + message.usage.cacheRead; + if (inputTokens > contextWindow) { + return true; + } + } + if (contextWindow && message.stopReason === "length" && message.usage.output === 0) { + const inputTokens = message.usage.input + message.usage.cacheRead; + if (inputTokens >= contextWindow * 0.99) { + return true; + } + } + return false; +} + +// node_modules/@earendil-works/pi-durable/dist/harness/inbox.js +var InboxDoc = defineDoc({ + kind: "pi.inbox", + version: 1, + scope: "conversation", + history: "latest", + fork: "initial", + initial: () => ({ items: [] }), + checkpointWhen: (value2) => value2.items.length === 0 +}); +async function prepareBoundary(tx, conversationId, modes) { + const head = (await tx.latestHeadMarker(conversationId))?.head; + const inbox = await tx.doc(InboxDoc, conversationId); + return { + conversationId, + inbox, + steeringMode: modes.steeringMode, + followUpMode: modes.followUpMode, + head + }; +} +async function applyBoundary(tx, boundary, at, now) { + const { conversationId, inbox } = boundary; + const items = inbox.items; + const reset = hasQueuedReset(items); + const final = at === "final" || reset; + const pick = (mode, queueMode) => { + const indexes = items.flatMap((item, index) => item.mode === mode ? [index] : []); + return queueMode === "all" ? indexes : indexes.slice(0, 1); + }; + const writes = items.flatMap((item, index) => item.mode === "write" ? [index] : []); + const users = [...pick("steer", boundary.steeringMode), ...final ? pick("followUp", boundary.followUpMode) : []]; + users.sort((a, b) => a - b); + let first; + for (const index of writes) { + const item = items[index]; + const draft = item.entry; + if (isStale(boundary, draft)) { + tx.settleSubmission(item.id, { status: "unanswered", reason: "stale" }); + continue; + } + const entry2 = await tx.appendEntry(conversationId, draft); + if (draft.head !== void 0) + boundary.head = draft.head === "self" ? entry2.id : draft.head; + tx.placeSubmission(item.id, entry2.id); + first ??= entry2.id; + } + const placed = []; + for (const index of users) { + const item = items[index]; + const entry2 = await appendInput(tx, conversationId, item.content, item.entry, now); + tx.placeSubmission(item.id, entry2.id); + placed.push(item.id); + first ??= entry2.id; + } + const removed = [...writes, ...users].sort((a, b) => b - a); + for (const index of removed) + items.splice(index, 1); + return { users: placed, first, reset }; +} +function appendInput(tx, conversationId, content, entry2, now) { + const message = { role: "user", content, timestamp: now }; + if (entry2 === void 0) + return tx.appendEntry(UserEntry, conversationId, { model: [message] }); + return tx.appendEntry(conversationId, { ...pickInputEntry(entry2), model: [message] }); +} +function pickInputEntry({ kind, data }) { + const picked = data === void 0 ? { kind } : { kind, data }; + return copyJson(picked, { omitUndefinedProperties: true }); +} +function isDisplayOnly(entry2) { + return entry2.model === void 0 && entry2.head === void 0 && entry2.edits === void 0; +} +function hasQueuedReset(items) { + return items.some((item) => item.mode === "write" && item.entry.head === "self"); +} +function isStale(boundary, entry2) { + return typeof entry2.head === "number" && boundary.head !== void 0 && entry2.head < boundary.head; +} +async function removeInboxItem(tx, conversationId, id) { + const items = (await tx.doc(InboxDoc, conversationId)).items; + const index = items.findIndex((item) => item.id === id); + if (index >= 0) + items.splice(index, 1); +} +async function withdrawQueuedInputs(tx, conversationId, reason = "aborted") { + const items = (await tx.doc(InboxDoc, conversationId)).items; + for (let index = items.length - 1; index >= 0; index--) { + const item = items[index]; + if (item.mode === "write") + continue; + tx.settleSubmission(item.id, { status: "unanswered", reason }); + items.splice(index, 1); + } +} + +// node_modules/@earendil-works/pi-durable/dist/harness/json.js +function assignJson(target, key, value2, plan) { + if (plan?.kind === "keep") + return; + const slots = target; + const current = key === "__proto__" && !Object.hasOwn(slots, key) ? void 0 : slots[key]; + const fields = plan?.kind === "fields" ? plan.fields : void 0; + if (isRecord(current) && isRecord(value2)) { + for (const name of Object.keys(current)) + if (!Object.hasOwn(value2, name)) + delete current[name]; + for (const [name, child] of Object.entries(value2)) + assignJson(current, name, child, fields?.get(name)); + return; + } + if (Array.isArray(current) && Array.isArray(value2) && current.length <= value2.length) { + const items = current; + for (let index = 0; index < value2.length; index++) { + if (index < items.length) + assignJson(items, index, value2[index], fields?.get(index)); + else + items.push(value2[index]); + } + return; + } + if (plan?.kind === "append" && typeof current === "string" && typeof value2 === "string" && !Array.isArray(target) && current.length + plan.text.length === value2.length) { + appendText(target, String(key), plan.text); + return; + } + if (current !== value2) + slots[key] = value2; +} +function isRecord(value2) { + return typeof value2 === "object" && value2 !== null && !Array.isArray(value2); +} + +// node_modules/@earendil-works/pi-durable/dist/harness/partial-changes.js +var PartialChanges = class { + #text = /* @__PURE__ */ new Map(); + #toolCalls = /* @__PURE__ */ new Map(); + /** Record what `event` changed. */ + observe(event) { + switch (event.type) { + case "text_delta": + case "thinking_delta": { + const field = event.type === "text_delta" ? "text" : "thinking"; + const pending = this.#text.get(event.contentIndex); + if (pending?.field === field) + pending.text += event.delta; + else + this.#text.set(event.contentIndex, { field, text: event.delta }); + return; + } + case "toolcall_start": + this.#toolCalls.set(event.contentIndex, { + parser: new StreamingJsonParser(), + changes: [], + committed: false + }); + return; + case "toolcall_delta": { + const call = this.#toolCalls.get(event.contentIndex); + if (call === void 0) + return; + for (const change of call.parser.append(event.delta)) { + if (change.path.length === 0 && !isRecord2(change.kind === "set" ? value(change) : null)) { + this.#toolCalls.delete(event.contentIndex); + return; + } + call.changes.push(change); + } + return; + } + case "text_end": + case "thinking_end": + this.#text.delete(event.contentIndex); + return; + case "toolcall_end": + this.#toolCalls.delete(event.contentIndex); + return; + } + } + /** + * Take what changed since the last call, for a commit of `message`, a copy of the partial. Clears the arguments of + * tracked tool calls in `message`: the commit writes them from the changes. Call `restore()` if the commit fails. + */ + take(message) { + const blocks = /* @__PURE__ */ new Map(); + for (const [index, { field, text }] of this.#text) { + blocks.set(index, { kind: "fields", fields: /* @__PURE__ */ new Map([[field, { kind: "append", text }]]) }); + } + this.#text = /* @__PURE__ */ new Map(); + const toolCalls = /* @__PURE__ */ new Map(); + for (const [index, call] of this.#toolCalls) { + const block = message.content[index]; + if (block?.type !== "toolCall") + continue; + toolCalls.set(index, { changes: call.changes, snapshot: block.arguments }); + block.arguments = {}; + blocks.set(index, { kind: "fields", fields: /* @__PURE__ */ new Map([["arguments", { kind: "keep" }]]) }); + call.changes = []; + } + return { + plan: { kind: "fields", fields: /* @__PURE__ */ new Map([["content", { kind: "fields", fields: blocks }]]) }, + toolCalls + }; + } + /** + * Write `message` with what `commit` took into `generation.message`: the message leaf by leaf with the plan, then the + * argument changes. A tracked call whose block is new here gets its arguments from its changes, which then start at + * its first piece; when the committed message lost a call whose changes were already committed (another writer reset + * it), the call falls back to its snapshot from now on. + */ + commit(generation, message, commit) { + const committedBlocks = generation.message?.content.length ?? 0; + const apply2 = []; + for (const [index, { changes, snapshot }] of commit.toolCalls) { + const call = this.#toolCalls.get(index); + if (call === void 0) + continue; + const block = message.content[index]; + if (index >= committedBlocks && call.committed && block?.type === "toolCall") { + this.#toolCalls.delete(index); + block.arguments = snapshot; + continue; + } + call.committed = true; + apply2.push([index, changes]); + } + assignJson(generation, "message", message, commit.plan); + const content = generation.message?.content ?? []; + for (const [index, changes] of apply2) { + const block = content[index]; + if (block?.type !== "toolCall") + continue; + for (const change of changes) + applyChange2(block, change); + } + } + /** Put back what a failed commit took, so the next commit applies it first. */ + restore(commit) { + for (const [index, { changes }] of commit.toolCalls) { + const call = this.#toolCalls.get(index); + if (call !== void 0) + call.changes = [...changes, ...call.changes]; + } + } +}; +function applyChange2(block, change) { + const key = change.path.at(-1); + if (key === void 0) { + const root2 = change.kind === "set" ? value(change) : void 0; + if (isRecord2(root2)) + block.arguments = root2; + return; + } + let parent = block.arguments; + for (const segment of change.path.slice(0, -1)) { + parent = parent[segment]; + } + const slots = parent; + if (change.kind === "set") + slots[key] = value(change); + else if (Array.isArray(parent)) + slots[key] = `${slots[key]}${change.text}`; + else + appendText(parent, String(key), change.text); +} +function value(change) { + return change.value; +} +function isRecord2(value2) { + return typeof value2 === "object" && value2 !== null && !Array.isArray(value2); +} + +// node_modules/@earendil-works/pi-durable/dist/harness/prompt.js +function replaySections(messages) { + const shown = /* @__PURE__ */ new Map(); + for (const message of messages) { + if (message.role !== "system" || message.sections === void 0) + continue; + for (const [key, value2] of Object.entries(message.sections)) { + if (value2 === null) + shown.delete(key); + else + shown.set(key, value2); + } + } + return shown; +} +async function renderSections(sections, input, shown, report2, context) { + const desired = /* @__PURE__ */ new Map(); + for (const section2 of sections) { + let text; + try { + text = await section2.render(input, context); + } catch (error) { + if (context.abortSignal?.aborted) + throw error; + report2(error, section2); + const kept = shown.get(section2.key); + if (kept !== void 0) + desired.set(section2.key, kept); + continue; + } + if (text === void 0) + continue; + desired.set(section2.key, section2.tag === false ? text : `<${section2.key}> +${text} +`); + } + return desired; +} +function planSystemEntries(view2, desired, tools, timestamp) { + const head = view2.head; + if (head !== void 0 && !view2.entries.some((entry2) => SystemEntry.is(entry2) && entry2.id > head.id)) { + const edits = view2.entries.filter((entry2) => SystemEntry.is(entry2)).map((entry2) => ({ target: entry2.id, action: "omit" })); + const baseline = systemEntry(Object.fromEntries(desired), { toolsRemoved: [], toolsAdded: tools.map(toToolDeclaration) }, timestamp); + return [edits.length === 0 ? baseline : { ...baseline, edits }]; + } + const sections = planSections(replaySections(view2.messages), desired); + const changes = planTools(getCurrentTools(view2.messages), tools); + if (changes.toolsRemoved.length === 0 && changes.toolsAdded.length === 0) { + return sections.map((patch) => systemEntry(patch, void 0, timestamp)); + } + if (sections.length === 0) + return [systemEntry(void 0, changes, timestamp)]; + return sections.map((patch, index) => systemEntry(patch, index === sections.length - 1 ? changes : void 0, timestamp)); +} +function planTools(offered, desired) { + const wanted = new Map(desired.map((tool) => [tool.name, tool])); + const kept = offered.filter((tool) => { + const next = wanted.get(tool.name); + return next !== void 0 && declarationsEqual(tool, next); + }); + const keptNames = new Set(kept.map((tool) => tool.name)); + const added = desired.filter((tool) => !keptNames.has(tool.name)); + const replayed = [...kept, ...added]; + if (replayed.some((tool, index) => tool.name !== desired[index].name)) { + return { + toolsRemoved: offered.map((tool) => ({ name: tool.name })), + toolsAdded: desired.map(toToolDeclaration) + }; + } + return { + toolsRemoved: offered.filter((tool) => !keptNames.has(tool.name)).map((tool) => ({ name: tool.name })), + toolsAdded: added.map(toToolDeclaration) + }; +} +function planSections(shown, desired) { + const patchedOrder = [ + ...[...shown.keys()].filter((key) => desired.has(key)), + ...[...desired.keys()].filter((key) => !shown.has(key)) + ]; + const desiredOrder = [...desired.keys()]; + if (patchedOrder.some((key, index) => key !== desiredOrder[index])) { + return [Object.fromEntries([...shown.keys()].map((key) => [key, null])), Object.fromEntries(desired)]; + } + const patch = {}; + for (const [key, value2] of shown) { + const next = desired.get(key); + if (next !== value2) + patch[key] = next ?? null; + } + for (const [key, value2] of desired) + if (!shown.has(key)) + patch[key] = value2; + return Object.keys(patch).length === 0 ? [] : [patch]; +} +function systemEntry(sections, tools, timestamp) { + const message = { + role: "system", + content: "", + ...sections === void 0 ? {} : { sections }, + ...tools === void 0 || tools.toolsRemoved.length === 0 ? {} : { toolsRemoved: tools.toolsRemoved }, + ...tools === void 0 || tools.toolsAdded.length === 0 ? {} : { toolsAdded: tools.toolsAdded }, + timestamp + }; + return { model: [message] }; +} + +// node_modules/@earendil-works/pi-ai/dist/utils/uuid.js +var MAX_UUID_V7_TIMESTAMP = 281474976710655; +var MAX_SEQUENCE = (1n << 41n) - 1n; +var lastOrdinaryTimestamp = -1; +var sequence; +function uuidv7(timestampMs) { + const requestedTimestamp = timestampMs ?? Date.now(); + if (!Number.isInteger(requestedTimestamp) || requestedTimestamp < 0 || requestedTimestamp > MAX_UUID_V7_TIMESTAMP) { + throw new RangeError(`UUIDv7 timestamp must be an integer between 0 and ${MAX_UUID_V7_TIMESTAMP}`); + } + const effectiveTimestamp = timestampMs === void 0 ? Math.max(requestedTimestamp, lastOrdinaryTimestamp) : timestampMs; + if (timestampMs === void 0) + lastOrdinaryTimestamp = effectiveTimestamp; + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + if (sequence === void 0) { + sequence = BigInt(bytes[1]) << 32n | BigInt(bytes[2]) << 24n | BigInt(bytes[3]) << 16n | BigInt(bytes[4]) << 8n | BigInt(bytes[5]); + } else { + if (sequence === MAX_SEQUENCE) + throw new RangeError("UUIDv7 generator sequence exhausted"); + sequence++; + } + const timestamp = BigInt(effectiveTimestamp); + for (let index = 5; index >= 0; index--) { + bytes[index] = Number(timestamp >> BigInt((5 - index) * 8)) & 255; + } + bytes[6] = 112 | Number(sequence >> 37n & 0x0fn); + bytes[7] = Number(sequence >> 29n & 0xffn); + bytes[8] = 128 | Number(sequence >> 23n & 0x3fn); + bytes[9] = Number(sequence >> 15n & 0xffn); + bytes[10] = Number(sequence >> 7n & 0xffn); + bytes[11] = Number((sequence & 0x7fn) << 1n) | bytes[11] & 1; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`; +} + +// node_modules/@earendil-works/pi-durable/dist/harness/provider.js +var ProviderDoc = defineDoc({ + kind: "pi.provider", + version: 1, + scope: "conversation", + history: "latest", + fork: "initial", + initial: () => ({ sessionId: uuidv7() }), + checkpointWhen: () => true +}); +async function ensureProviderSessionId(runtime, context) { + const existing = await runtime.snapshot(ProviderDoc, runtime.conversationId, context); + if (existing !== void 0) + return existing.sessionId; + let created; + await runtime.commit(async (tx) => { + created = (await tx.doc(ProviderDoc, runtime.conversationId)).sessionId; + return void 0; + }, context); + if (created === void 0) + throw new Error(`Conversation ${runtime.conversationId} has no provider session ID`); + return created; +} + +// node_modules/@earendil-works/pi-durable/dist/harness/telemetry.js +var entry = { + description: "A transcript entry was committed; content remains in the transcript.", + attributes: { + "entry.id": { type: "number", required: true, description: "Transcript entry ID.", cardinality: "high" }, + "entry.kind": { type: "string", required: true, description: "Entry kind." } + } +}; +var reportAttributes = { + type: { + type: "string", + required: true, + description: "Report kind (`Report.kind`).", + values: [ + "storage_failed", + "task_faulted", + "task_interrupted", + "migration_failed", + "task_outdated", + "task_error", + "extension_error", + "host_error", + "listener_error" + ] + }, + reason: { + type: "string", + required: false, + description: "Fixed report reason, when present.", + values: ["threw", "restart_limit", "interrupted", "missing_task", "task_too_old", "migration_failed"] + }, + extension: { type: "string", required: false, description: "Registered extension name, when present." }, + "task.id": { type: "number", required: false, description: "Task ID, when present.", cardinality: "high" } +}; +var report = { + description: "A report was raised; no error payload is recorded.", + attributes: reportAttributes +}; +var submission = { + description: "A submission was settled.", + attributes: { + "submission.id": { type: "number", required: true, description: "Submission ID.", cardinality: "high" }, + status: { type: "string", required: true, values: ["done", "unanswered"], description: "Settlement." }, + "answer.entry_id": { type: "number", required: false, description: "Answer entry ID.", cardinality: "high" } + } +}; +var DURABLE_TELEMETRY_SCHEMA = defineTelemetrySchema({ + version: 1, + spans: { + "pi.durable.submit": { + description: "Public submission admission: with its commit for submit(draft, context); the admission only for submit(tx, draft), whose result is provisional (a later throw or storage failure of the caller's commit leaves no submission) and whose commit events go to the caller's commit context. Inputs and continues start root traces linked to their callers; writes are children. Internal admissions emit phase events only.", + parents: { kind: "any" }, + startAttributes: { + "submission.type": { + type: "string", + required: true, + values: ["input", "write", "continue"], + description: "Submission type." + }, + "conversation.id": { type: "number", required: true, description: "Conversation ID.", cardinality: "high" } + }, + endAttributes: { + "submission.id": { type: "number", description: "Admitted submission ID.", cardinality: "high" }, + result: { + type: "string", + values: ["queued", "placed", "done"], + description: "Admission result; stale writes report done." + } + }, + events: { "pi.durable.entry": entry, "pi.durable.submission": submission, "pi.durable.report": report }, + status: { default: "ok", errorWhen: "Admission failed." } + }, + "pi.durable.phase": { + description: "One task phase invocation, including its decision commit, never a wait or restart.", + parents: { kind: "any" }, + startAttributes: { + "task.id": { type: "number", required: true, description: "Task ID.", cardinality: "high" }, + "task.name": { type: "string", required: true, description: "Registered task name." }, + phase: { type: "string", required: true, description: "Invoked checkpoint phase." }, + kind: { type: "string", required: true, values: ["run", "abort"], description: "Invocation kind." }, + attempt: { type: "number", required: false, description: "Checkpoint attempt, when present." }, + resumed: { + type: "boolean", + required: true, + description: "First invocation of a task loaded in this process." + } + }, + endAttributes: { + outcome: { type: "string", description: "next=, waiting, sleeping, done, faulted, or aborted." }, + "tool.name": { type: "string", description: "Called tool name." }, + "tool.call_id": { type: "string", description: "Transcript tool call ID.", cardinality: "high" } + }, + events: { + "pi.durable.entry": entry, + "pi.durable.submission": submission, + "pi.durable.report": report, + "pi.durable.checkpoint": { + description: "The running checkpoint's phase changed while the invocation continued.", + attributes: { phase: { type: "string", required: true, description: "Committed checkpoint phase." } } + }, + "pi.durable.replay": { + description: "A nested tool call was answered from its stored result.", + attributes: { "tool.name": { type: "string", required: true, description: "Called tool name." } } + } + }, + status: { + default: "ok", + errorWhen: "Faulted, failed, and orphaned outcomes use task_faulted, task_failed, and task_orphaned with a diagnostic capped at 500 characters. Aborted outcomes keep status ok." + } + } + }, + metrics: { + "pi.durable.reports": { + kind: "counter", + description: "Reports raised, grouped by their fixed kind.", + attributes: { type: reportAttributes.type } + }, + "pi.durable.phase.duration": { + kind: "histogram", + unit: "ms", + description: "Duration of a phase invocation and its decision commit.", + attributes: { + task: { type: "string", required: true, description: "Registered task name." }, + phase: { type: "string", required: true, description: "Invoked checkpoint phase." }, + outcome: { type: "string", required: true, description: "Phase outcome." } + } + }, + "pi.durable.tasks.active": { + kind: "upDownCounter", + description: "Live tasks held by this Harness; decremented at terminal settlement or close.", + attributes: { task: { type: "string", required: true, description: "Registered task name." } } + } + } +}); + +// node_modules/@earendil-works/pi-durable/dist/harness/util.js +var Waiters = class { + #sets = /* @__PURE__ */ new Map(); + add(key, context) { + const signal = context.abortSignal; + if (signal?.aborted) + return Promise.reject(signal.reason); + const waiter = Promise.withResolvers(); + const set = this.#sets.get(key) ?? /* @__PURE__ */ new Set(); + this.#sets.set(key, set.add(waiter)); + if (signal !== void 0) { + const onAbort = () => { + set.delete(waiter); + if (set.size === 0 && this.#sets.get(key) === set) + this.#sets.delete(key); + waiter.reject(signal.reason); + }; + signal.addEventListener("abort", onAbort, { once: true }); + const detach = () => signal.removeEventListener("abort", onAbort); + waiter.promise.then(detach, detach); + } + return waiter.promise; + } + keys() { + return [...this.#sets.keys()]; + } + resolve(key, value2) { + const set = this.#sets.get(key); + this.#sets.delete(key); + for (const waiter of set ?? []) + waiter.resolve(value2); + } + rejectAll(error) { + const sets = [...this.#sets.values()]; + this.#sets.clear(); + for (const set of sets) + for (const waiter of set) + waiter.reject(error); + } +}; +async function scanAll(scan) { + const items = []; + let cursor; + do { + const page2 = await scan(cursor); + items.push(...page2.items); + cursor = page2.next; + } while (cursor !== void 0); + return items; +} +function closedError(session) { + const failure = session.failure; + return failure === void 0 ? new Error("Harness is closed") : new SessionFailed(failure.error); +} +function optionalExtension(extension) { + return extension === void 0 ? {} : { extension }; +} +function conversationEntries(conversationId, query) { + return { + conversationId, + ...query.minEntryId === void 0 ? {} : { minEntryId: query.minEntryId }, + ...query.maxEntryId === void 0 ? {} : { maxEntryId: query.maxEntryId }, + ...query.order === void 0 ? {} : { order: query.order } + }; +} + +// node_modules/@earendil-works/pi-durable/dist/harness/scheduler.js +var SCAN_PAGE_SIZE3 = 256; +var NO_KEYS2 = []; +var LIVE_STATUSES = ["pending", "running", "waiting", "sleeping", "completing"]; +function blockedSubject(record) { + return { + id: record.id, + kind: record.kind, + version: record.version, + abortRequested: record.abortRequested, + ...record.abortReason === void 0 ? {} : { abortReason: record.abortReason }, + status: record.state.status + }; +} +var CONTINUE = { kind: "continue" }; +var PROGRESS = { kind: "progress" }; +var END = { kind: "end" }; +var CASCADE = { kind: "cascade" }; +function conversationNode(id) { + return `c${id}`; +} +function conversationOf(node) { + return Number(node.slice(1)); +} +function parentNode(node) { + return node.owner ?? conversationNode(node.conversationId); +} +var TaskScheduler = class { + #telemetry; + #activeMetric; + #durationMetric; + #resumed = /* @__PURE__ */ new Set(); + #session; + #storage; + #registry; + #models; + #agent; + #settings; + #env; + #now; + #onWake; + #timers; + #writeAtClose; + #report; + #settleOutcome; + #limitsRestarts; + #withdrawInputs; + #conversation; + #context; + #live = /* @__PURE__ */ new Map(); + /** + * Context range last read through a task runtime, per conversation: a later read, by any of its tasks, scans only + * newer entries. `idleSince` is the Harness time it was first seen idle. Derived and never persisted; dropped at the + * first idle check after `settings.contextRetentionMs` of idleness, and at close. + */ + #contexts = /* @__PURE__ */ new Map(); + /** + * The idle kept contexts by `idleSince`, oldest first: the expiry order, so neither an expiry nor finding the next one + * scans `#contexts`. Kept sorted also when the Harness clock goes back (see `#markIdle`). + */ + #idleOrder = /* @__PURE__ */ new Map(); + /** At least the newest `idleSince` in `#idleOrder` (drops do not lower it): an older one added means the clock went back. */ + #idleNewest = Number.NEGATIVE_INFINITY; + /** + * A chain load resolved what `#idle()` treats as unknown for a live task whose chain is not loaded, which can make a + * scope idle without a task change below it; the next idle check then covers every scope. + */ + #unknownsChanged = false; + /** Idle checks that covered every scope (`idleState`, a test diagnostic). */ + #fullIdleChecks = 0; + /** Background timer for the earliest expiry of an idle context, unless the host declined it (see `#scheduleExpiry`). */ + #expiry; + #invocations = /* @__PURE__ */ new Map(); + #taskWaiters = new Waiters(); + /** Idle waiters by conversation; `undefined` waits for the whole Harness. */ + #idleWaiters = new Waiters(); + /** Definition whose migration failed per task; retried only once the registry resolves another definition. */ + #failedMigrations = /* @__PURE__ */ new Map(); + /** Owner task of each known conversation, `null` when ownerless. */ + #edges = /* @__PURE__ */ new Map(); + /** Ownership fields of the known terminal tasks, which walks pass through. */ + #settled = /* @__PURE__ */ new Map(); + /** + * Ended tasks and conversations found unneeded, dropped by `#sweep()` at the end of the commit's publication unless + * needed again by then: a chain load installs nodes before the work below them is listed. + */ + #dropQueue = /* @__PURE__ */ new Set(); + /** + * The tree downward around live work: each node's child nodes that are live tasks or have nodes listed below them. + * A conversation's parent is its owner task; ownerless ones with work are in `#roots`. + */ + #below = /* @__PURE__ */ new Map(); + #roots = /* @__PURE__ */ new Set(); + /** Known tasks whose chain may not be loaded; every other known task's chain is, and stays, loaded. */ + #unloaded = /* @__PURE__ */ new Set(); + /** Live tasks with cancellation intent, the sources of abort cascades (spec §5.4). */ + #intent = /* @__PURE__ */ new Set(); + /** Live tasks that are not `completing`, the candidates of a reservation pass; ones found waiting leave it. */ + #runnable = /* @__PURE__ */ new Set(); + /** `completing` tasks the next finalize pass checks: those that started holding, and holders of ended work. */ + #finalizeChecks = /* @__PURE__ */ new Set(); + /** Live waiting tasks by each task in their `on`. */ + #waiters = /* @__PURE__ */ new Map(); + /** + * `failFast` waiters the next reconcile checks for a failed task in `on`: at open, when they start waiting, and when + * one of their tasks fails. + */ + #failFastChecks = /* @__PURE__ */ new Set(); + /** + * Live tasks with `abandonOnRestart` found at open, from an earlier Harness. The first reservation pass abort-marks + * them with reason `restart`, so nothing of them or below them runs a phase again. + */ + #abandoned = /* @__PURE__ */ new Set(); + /** Sleeping live tasks, due or not: the candidates of `#wakeDue()`. */ + #sleeping = /* @__PURE__ */ new Set(); + /** Timer for the earliest sleep, without `onWake` while scheduling runs. */ + #timer; + /** Last value given to `onWake`, so an unchanged one is not repeated. */ + #lastWake; + #wakeScheduled = false; + #blockedListeners = /* @__PURE__ */ new Set(); + /** A registry snapshot that threw for the task graph was reported; it is read per record, so only once. */ + #snapshotReported = false; + #reconcileScheduled = false; + /** Reconcile passes underway with work (they may overlap): their commits finalize held outcomes and mark cascades. */ + #reconcilingWork = 0; + #sweepScheduled = false; + /** + * Nodes chain loads installed for jobs whose commits are not published yet, with how many such jobs: the sweep keeps + * them, as work a pending commit creates below them needs them. + */ + #pins = /* @__PURE__ */ new Map(); + /** Keys the decision region underway reads (`#region()`); set only while one runs. */ + #reading; + #cascadePending = false; + #unsubscribeRegistry = () => { + }; + #enabled = false; + #readOnly; + #closing = false; + #dirty = false; + #draining = false; + constructor(options) { + this.#telemetry = options.telemetry ?? NOOP_TELEMETRY_CONTEXT; + const metrics = createTypedMetrics(this.#telemetry.metrics ?? NOOP_TELEMETRY_METRICS, [DURABLE_TELEMETRY_SCHEMA]); + this.#activeMetric = metrics.upDownCounter("pi.durable.tasks.active"); + this.#durationMetric = metrics.histogram("pi.durable.phase.duration"); + this.#session = options.session; + this.#storage = options.storage; + this.#registry = options.registry; + this.#models = options.models; + this.#agent = options.agent; + this.#settings = options.settings; + this.#env = options.env; + this.#now = options.now; + this.#onWake = options.onWake; + this.#timers = options.timers; + this.#writeAtClose = options.writeAtClose; + this.#report = options.report; + this.#settleOutcome = options.settleOutcome; + this.#limitsRestarts = options.limitsRestarts; + this.#withdrawInputs = options.withdrawInputs; + this.#conversation = options.conversation; + this.#context = options.context; + this.#readOnly = options.readOnly === true; + } + /** + * Load live tasks and change surviving `running` tasks back to `pending`, counting the restart. Dispatches nothing; + * a task cut off `settings.restartLimit` times ends when it is next reserved. Read-only, it only loads them: a + * `running` task stays as committed, running in another process. + */ + async open(context) { + this.#session.followCommits((publication) => this.#observe(publication)); + this.#session.subscribeClose(() => this.#seal()); + this.#unsubscribeRegistry = this.#registry.subscribe(() => { + this.#notifyBlocked(); + this.#kick(); + }); + await this.#session.commitWith(async (tx) => { + await this.#published(); + const scans = []; + for (const status of LIVE_STATUSES) { + scans.push(await scanAll((cursor) => tx.scanTasks({ status }, SCAN_PAGE_SIZE3, cursor))); + } + for (const records of scans) { + for (const record of records) { + this.#track(void 0, record); + this.#resumed.add(record.id); + if (this.#readOnly) + continue; + if (record.abandonOnRestart === true) + this.#abandoned.add(record.id); + if (record.state.status === "running") { + const restarts = (record.restarts ?? 0) + 1; + const pending = withState(record, { status: "pending", checkpoint: record.state.checkpoint }); + tx.setTask({ ...pending, restarts }); + } + if (record.state.status === "waiting" && record.state.policy === "failFast") { + this.#failFastChecks.add(record.id); + } + } + } + }, context); + if (this.#readOnly) + return; + this.#cascadePending = true; + this.#scheduleReconcile(); + this.#scheduleWake(); + } + /** + * Enable scheduling, and make sleeping tasks that are due runnable: a host wakeup, such as an alarm, calls this. + * Idempotent; the kick does nothing once closing. + */ + resume() { + if (this.#readOnly) + throw new ReadOnlySession("running tasks"); + this.#enabled = true; + this.#wakeDue(); + this.#kick(); + } + /** + * Why a live task cannot run here, if it cannot: its definition is missing, too old, or failed to migrate. A running + * task, an abort-marked one, and one holding its outcome are never blocked. + */ + blockedReason(record) { + const status = record.status; + if (status === "running" || status === "completing" || status === "terminal") + return void 0; + if (record.abortRequested && record.abortReason === void 0) + return void 0; + let snapshot; + try { + snapshot = this.#registry.snapshot(); + } catch (error) { + if (!this.#snapshotReported) + this.#report(error, REGISTRY); + this.#snapshotReported = true; + return void 0; + } + const fit = this.#fit(record, snapshot.task(record.kind)); + if ("reason" in fit) + return fit.reason; + return fit.migrates && fit.task.definition.migrate === void 0 ? "migration_failed" : void 0; + } + /** Call `listener` when blocked reasons may have changed without a commit: a registry change or a failed migration. */ + subscribeBlocked(listener) { + this.#blockedListeners.add(listener); + return () => this.#blockedListeners.delete(listener); + } + /** + * When the Session next needs a process: `active` while some task can run now or is running, `at` for the earliest + * sleep not yet due. Synchronous, from memory; a task whose chain is not loaded yet counts as it would alone. + */ + wake() { + if (this.#readOnly) + return { active: false }; + const now = this.#now(); + const active = this.#invocations.size > 0 || this.#reconcilingWork > 0 || this.#reconcileWork() || this.#runsNow(now); + let at; + for (const id of this.#sleeping) { + const record = this.#live.get(id); + const state = record?.state; + if (state?.status === "sleeping" && !record.abortRequested && state.until > now) { + at = at === void 0 ? state.until : Math.min(at, state.until); + } + } + return at === void 0 ? { active } : { active, at }; + } + /** Whether a live task can run now: the scan `wake()` needs only while no invocation runs. */ + #runsNow(now) { + let snapshot; + let snapshotFailed = false; + for (const record of this.#live.values()) { + const state = record.state; + if (state.status === "completing") + continue; + if (state.status === "waiting" && !record.abortRequested && state.on.some((id) => this.#live.has(id))) + continue; + if (state.status === "sleeping" && !record.abortRequested && state.until > now) + continue; + if (record.abortRequested && this.#waitingOn(record, true).length > 0) + continue; + if (!record.abortRequested || record.abortReason !== void 0) { + if (snapshot === void 0 && !snapshotFailed) { + try { + snapshot = this.#registry.snapshot(); + } catch (error) { + snapshotFailed = true; + this.#report(error, REGISTRY); + } + } + if (snapshot !== void 0 && "reason" in this.#fit(record, snapshot.task(record.kind))) + continue; + } + return true; + } + return false; + } + /** + * Signal every invocation and wait for them, then write the tasks they ran back as `pending` (`writeAtClose`), as a + * close is not a crash. Close calls this after every close listener has run, so waits, watches, and streams a task + * holds end with the Session's reason, not as cancelled by its signal. + */ + async join() { + const stopped = [...this.#invocations.values()]; + for (const invocation of stopped) + invocation.controller.abort(); + await Promise.allSettled(stopped.map((invocation) => invocation.done)); + if (stopped.length === 0) + return; + await this.#writeAtClose(() => stopped.flatMap((invocation) => { + const record = this.#live.get(invocation.taskId); + if (record?.state.status !== "running") + return []; + const value2 = withState(record, { status: "pending", checkpoint: record.state.checkpoint }); + return [{ type: "task", value: value2 }]; + })).catch(() => { + }); + } + /** + * Commit the abort mark, or settle a task that no registered definition can take as `orphaned` when nothing it owns + * is live, then join the run invocation seen on the line; the commit listener signalled it. The abort invocation + * starts once the task's ordinary owned work is gone. A `completing` task is only marked. A request replaces a + * `restart` mark, so a task abandoned after a restart that waits for its definition is orphaned; with `keepRestart`, + * as for an owner's own cleanup, such a task keeps its mark and waits on. + */ + async abort(id, context, keepRestart = false) { + const marked = await this.#session.commitWith(async (tx) => { + const current = await tx.task(id); + if (current === void 0) + throw new Error(`Task ${id} does not exist`); + if (current.state.status === "terminal") + return { result: "terminal" }; + const invocation = this.#invocations.get(id); + if (keepRestart && current.abortReason === "restart") + return { result: "marked" }; + if (invocation === void 0 && current.state.status !== "completing") { + await this.#loadScopes(false); + const owns = await this.#region(() => this.#hasOwnedLive(id), this.#reloadScopes); + const resolution = owns ? void 0 : this.#resolve(current, this.#registry.snapshot()); + if (resolution?.kind === "blocked") { + await this.#terminate(tx, current, { status: "orphaned", reason: resolution.reason }); + return { result: "marked" }; + } + } + if (!current.abortRequested || current.abortReason !== void 0) + tx.setTask(withAbortMark(current)); + return { result: "marked", run: invocation?.mode === "run" ? invocation : void 0 }; + }, context); + if (marked.run !== void 0) { + await awaitWithContext(Promise.race([marked.run.done, this.#session.failed]), context); + } + return marked.result; + } + async waitForTask(id, context) { + const found = await this.#session.readOnLine(async (storage) => { + if (this.#closing) + throw closedError(this.#session); + if (await this.#region(() => this.#liveRecord(id) !== void 0)) { + return { promise: this.#taskWaiters.add(id, context) }; + } + const record = await storage.task(id, context); + if (record === void 0) + throw new Error(`Task ${id} does not exist`); + return { promise: Promise.resolve(record) }; + }); + return found.promise; + } + /** + * Resolve when ordinary traversal from the conversation, or from every ownerless conversation, reaches no live + * non-background task. + */ + waitForIdle(conversationId, context) { + if (this.#closing) + return Promise.reject(closedError(this.#session)); + if (this.#idle(conversationId)) + return Promise.resolve(); + this.#scheduleReconcile(); + return this.#idleWaiters.add(conversationId, context); + } + /** + * `Conversation.abort()`: in one commit, withdraw the queued inputs and mark every live non-background task that + * ordinary traversal from the conversation reaches; resolves once the scope is idle. With `background`, traversal + * crosses background boundaries, and the wait also covers every task it reached. + */ + async abortConversation(conversationId, background, context) { + const reached = await this.#session.commitWith(async (tx) => { + await this.#published(); + const queued = await this.#loadScopes(true); + const scope = { conversation: conversationId }; + const reached2 = []; + const walk = this.#walkDown(conversationNode(conversationId), { crossBackground: background }); + for (const { node, live } of walk) { + if (live === void 0 || node.background && !background) + continue; + reached2.push(live.id); + if (!live.abortRequested || live.abortReason !== void 0) + tx.setTask(withAbortMark(live)); + } + for (const id of queued) { + if (this.#inScope({ conversation: id }, scope, background) === true) + await this.#withdrawInputs(tx, id); + } + return reached2; + }, context); + if (background) + for (const id of reached) + await this.waitForTask(id, context); + await this.waitForIdle(conversationId, context); + } + /** + * The ownership memberships a commit's task and conversation changes write (group commit §4.2), at its admission on + * the line: for each task created or made terminal, every node from its parent up to and including the nearest live + * task, or the root conversation, and then the roots too, conservatively, as a pending end may unlist the root a + * creation finds listed; for each conversation created under a task, that task; and the intent set when + * a change sets, upgrades, or clears a mark or a held or ended outcome other than `completed`. Loads the chains it + * walks; the nodes it installs stay until the commit is published. + */ + membershipKeys(tx) { + const tasks = tx.stagedTasks(); + const conversations = tx.stagedConversations(); + if (tasks.length === 0 && conversations.every((record) => record.owner === void 0)) + return NO_KEYS2; + return this.#membershipKeys(tx, tasks, conversations); + } + async #membershipKeys(tx, tasks, conversations) { + const keys = /* @__PURE__ */ new Set(); + const overlay = overlayOf(tx); + const created = new Set(tx.createdTaskIds()); + const reached = /* @__PURE__ */ new Map(); + for (const record of tasks) { + if (intentChanges(this.#live.get(record.id), record)) + keys.add(INTENT_KEY); + if (!created.has(record.id) && record.state.status !== "terminal") + continue; + await this.#loadChain(parentOf(record), overlay); + if (await this.#chainKeys(parentNode(record), overlay, keys, reached) !== void 0) + keys.add(ROOTS_KEY); + } + for (const conversation of conversations) { + const owner = conversation.owner?.taskId; + if (owner === void 0) + continue; + await this.#loadChain({ task: owner }, overlay); + await this.#chainKeys(owner, overlay, keys, reached); + } + return [...keys]; + } + /** + * Add the memberships from `start` up to and including the nearest live task, or the root conversation, which it + * returns. `reached` keeps, per node walked, where its walk ended, so a chain several tasks of a commit share is + * walked once. Whether an ancestor is live can change under a pending commit, so it is read after a touch of the + * ancestor's record: a pending end must not stop the chain early. Ownership fields are immutable, so not keyed. + */ + async #chainKeys(start, overlay, keys, reached) { + const walked = []; + let root2; + let node = start; + while (node !== void 0 && node !== null) { + const key = ownedKey(node); + if (reached.has(key)) { + root2 = reached.get(key); + break; + } + keys.add(key); + walked.push(key); + if (typeof node === "string") { + const conversation = conversationOf(node); + node = this.#edge(conversation, overlay); + if (node === null) + root2 = conversation; + continue; + } + let record = overlay.tasks.get(node); + if (record === void 0) { + const gate = this.#session.touch(taskKey(node)); + if (gate !== void 0) { + await gate; + await this.#loadChain({ task: node }, overlay); + } + record = this.#live.get(node); + } + if (record !== void 0 && record.state.status !== "terminal") + break; + const above = record ?? this.#settled.get(node); + node = above === void 0 ? void 0 : parentNode(above); + } + for (const key of walked) + reached.set(key, root2); + return root2; + } + // ─── Scheduling ──────────────────────────────────────────────────────── + #observe(publication) { + if (publication.kind === "memory") + return; + this.#advanceContexts(publication); + const updated = []; + const failed = []; + let changed = false; + let dirty = /* @__PURE__ */ new Set(); + for (const change of publication.changes) { + if (change.type === "conversation" && !this.#edges.has(change.value.id)) { + this.#setEdge(change.value.id, change.value.owner?.taskId ?? null); + } + } + for (const change of publication.changes) { + if (change.type !== "task") + continue; + changed = true; + const record = change.value; + const previous = this.#live.get(record.id); + if (failedOutcome(record) && (previous === void 0 || !failedOutcome(previous))) + failed.push(record.id); + this.#track(previous, record); + const liveness = previous === void 0 || record.state.status === "terminal"; + if (liveness && dirty !== void 0 && !this.#addScopesAbove(record, dirty)) + dirty = void 0; + if (record.state.status === "terminal") { + this.#failedMigrations.delete(record.id); + this.#failFastChecks.delete(record.id); + this.#taskWaiters.resolve(record.id, record); + this.#scheduleReconcile(); + continue; + } + if (record.abortRequested && previous?.abortRequested !== true) { + this.#cascadePending = true; + const invocation = this.#invocations.get(record.id); + if (invocation?.mode === "run") + queueMicrotask(() => invocation.controller.abort()); + } + if (previous?.abortReason !== void 0 && record.abortReason === void 0) + this.#cascadePending = true; + const status = record.state.status; + if (status === "completing" && previous?.state.status !== "completing") { + if (cancellationIntent(record)) + this.#cascadePending = true; + this.#scheduleReconcile(); + } + if (status === "waiting" && record.state.policy === "failFast" && previous?.state.status !== "waiting") { + this.#failFastChecks.add(record.id); + this.#scheduleReconcile(); + } + if (previous === void 0) + updated.push(record); + } + for (const id of failed) { + for (const waiter of this.#waiters.get(id) ?? []) { + const state = this.#live.get(waiter)?.state; + if (state?.status === "waiting" && state.policy === "failFast") { + this.#failFastChecks.add(waiter); + this.#scheduleReconcile(); + } + } + } + for (const change of publication.changes) { + if (change.type !== "submission" || change.value.status !== "queued" || change.value.type !== "input") + continue; + if (this.#intent.size === 0) + continue; + const conversation = change.value.conversationId; + if (!this.#known(conversationNode(conversation)) || this.#belowCancelled({ conversation })) { + this.#cascadePending = true; + } + } + for (const record of updated) { + if (this.#unloaded.has(record.id)) + this.#scheduleReconcile(); + else if (!record.background && !record.abortRequested && this.#belowCancelled(parentOf(record))) { + this.#cascadePending = true; + } + } + if (this.#cascadePending) + this.#scheduleReconcile(); + this.#sweep(); + if (!changed) + return; + this.#settleIdle(dirty); + this.#kick(); + } + /** + * Add the conversations above `record` to `scopes`: those whose idleness a change of it can change. False when a node + * on the way up is not known. A conversation already in `scopes` has everything above it there too. + */ + #addScopesAbove(record, scopes) { + for (const step of this.#above(parentOf(record))) { + if ("unknown" in step) + return false; + if (!("conversation" in step)) + continue; + if (scopes.has(step.conversation)) + return true; + scopes.add(step.conversation); + } + return true; + } + /** + * Extend each kept context range with the entries a stored commit appended to its conversation, so a later read + * needs no Storage scan. Entries are published in ID order. + */ + #advanceContexts(publication) { + if (this.#contexts.size === 0) + return; + let appended; + for (const change of publication.changes) { + if (change.type !== "entry" || !this.#contexts.has(change.value.conversationId)) + continue; + appended ??= /* @__PURE__ */ new Map(); + const entries2 = appended.get(change.value.conversationId); + if (entries2 === void 0) + appended.set(change.value.conversationId, [change.value]); + else + entries2.push(change.value); + } + if (appended === void 0) + return; + for (const [conversationId, entries2] of appended) { + const kept = this.#contexts.get(conversationId); + if (kept.idleSince !== void 0 && kept.expiresAt !== void 0 && kept.expiresAt <= this.#now()) { + this.#dropContext(conversationId); + continue; + } + const range = advanceRange(kept.range, entries2); + if (range === void 0) + this.#dropContext(conversationId); + else + kept.range = range; + } + } + /** + * `settings.contextRetentionMs`, or 0 when the host's settings throw. Kept contexts are only a cache, so dropping them + * is safe, while a throw here would escape commit listeners, reconciliation, and the expiry timer. + */ + #contextRetentionMs() { + try { + return this.#settings().contextRetentionMs; + } catch (error) { + this.#report(error, SETTINGS); + return 0; + } + } + /** + * After task changes below the conversations in `scopes` (every conversation when undefined): resolve their idle + * waiters and the Harness-wide ones, drop the kept contexts whose idle period passed the retention, then start or end + * the idle periods of the kept contexts in `scopes`. A task change can change the idleness only of the conversations + * above it, so no check visits every conversation. + */ + #settleIdle(scopes) { + const all = scopes === void 0 || this.#unknownsChanged; + this.#unknownsChanged = false; + if (all) + this.#fullIdleChecks++; + this.#resolveIdleWaiters(all ? void 0 : scopes); + const now = this.#now(); + const retention = this.#contextRetentionMs(); + this.#expireContexts(now, retention); + for (const conversationId of all ? [...this.#contexts.keys()] : scopes) { + const kept = this.#contexts.get(conversationId); + if (kept === void 0) + continue; + if (!this.#idle(conversationId)) { + if (kept.idleSince === void 0) + continue; + kept.idleSince = void 0; + this.#idleOrder.delete(conversationId); + } else if (retention <= 0) { + this.#dropContext(conversationId); + } else if (kept.idleSince === void 0) { + kept.idleSince = now; + kept.expiresAt = now + retention; + this.#markIdle(conversationId, now); + } + } + this.#scheduleExpiry(); + } + /** Resolve the idle waiters of the conversations in `scopes` (all when undefined) and the Harness-wide ones. */ + #resolveIdleWaiters(scopes) { + for (const conversationId of this.#idleWaiters.keys()) { + if (conversationId !== void 0 && scopes !== void 0 && !scopes.has(conversationId)) + continue; + if (this.#idle(conversationId)) + this.#idleWaiters.resolve(conversationId); + } + } + /** + * Drop the kept contexts idle for at least `retention`, oldest first, with the retention setting of now; with no + * retention, every idle one, also those whose idle period a clock that went back makes start after now. + */ + #expireContexts(now, retention) { + for (const [conversationId, idleSince] of this.#idleOrder) { + if (retention > 0 && now - idleSince < retention) + return; + this.#dropContext(conversationId); + } + } + /** + * Start the idle period of a kept context in the expiry order. An idle period older than the newest one kept, which + * a clock that went back starts, sorts the order again, and the newest is taken from what is kept then. + */ + #markIdle(conversationId, idleSince) { + this.#idleOrder.delete(conversationId); + this.#idleOrder.set(conversationId, idleSince); + if (this.#idleOrder.size === 1 || idleSince >= this.#idleNewest) { + this.#idleNewest = idleSince; + return; + } + const sorted = [...this.#idleOrder].sort((a, b) => a[1] - b[1]); + this.#idleOrder.clear(); + for (const [id, since] of sorted) + this.#idleOrder.set(id, since); + this.#idleNewest = sorted.at(-1)[1]; + } + /** Forget a kept context, idle or not, and its place in the expiry order. */ + #dropContext(conversationId) { + this.#contexts.delete(conversationId); + this.#idleOrder.delete(conversationId); + } + /** + * Drop the expired contexts when the earliest idle context expires, on a background timer, which never keeps the + * process alive. Where the host declines it, as the default does in Cloudflare Workers, none is kept: a pending timer + * could keep a Durable Object from being evicted, and eviction frees the contexts. There, task changes alone check + * expiry. + */ + #scheduleExpiry() { + const oldest = this.#idleOrder.values().next(); + const at = oldest.done === true ? void 0 : oldest.value + this.#contextRetentionMs(); + if (this.#expiry !== void 0 && this.#expiry.at === at) + return; + this.#expiry?.disarm(); + this.#expiry = void 0; + if (at === void 0 || this.#closing) + return; + const disarm = this.#timers.background(() => { + this.#expiry = void 0; + this.#expireContexts(this.#now(), this.#contextRetentionMs()); + this.#scheduleExpiry(); + }, at - this.#now()); + if (disarm !== void 0) + this.#expiry = { at, disarm }; + } + // ─── Ownership ─────────────────────────────────────────────────────────── + #scheduleReconcile() { + if (this.#reconcileScheduled || this.#closing || this.#readOnly) + return; + this.#reconcileScheduled = true; + queueMicrotask(() => void this.#reconcile()); + } + /** + * One commit that applies what committed records imply: abort marks below live owners with cancellation intent + * (spec §5.4), `failFast` marks (spec §5.5), withdrawn queued inputs below cancelled owners, and the final terminal + * record of every `completing` task whose ordinary owned work is gone. The durable records are the intent, so this + * also repairs whatever a crash left unapplied. Resolves idle waiters that the loaded edges decide. + */ + async #reconcile() { + this.#reconcileScheduled = false; + const work = this.#reconcileWork(); + if (work) + this.#reconcilingWork++; + try { + await this.#session.commitInternal(async (tx) => { + if (this.#closing) + return; + const cascade = this.#cascadePending; + const queued = await this.#loadScopes(cascade); + if (cascade) + this.#cascadePending = false; + const marks = await this.#marks(); + for (const { record, reason } of marks.values()) + tx.setTask(withAbortMark(record, reason)); + const withdrawn = await this.#region(() => queued.filter((id) => this.#belowCancelled({ conversation: id })), this.#reloadScopes); + for (const id of withdrawn) + await this.#withdrawInputs(tx, id); + await this.#finalize(tx); + }, this.#context); + } catch (error) { + this.#failSession(error); + } + if (work) + this.#reconcilingWork--; + this.#scheduleWake(); + if (this.#unknownsChanged) { + this.#settleIdle(void 0); + return; + } + this.#resolveIdleWaiters(void 0); + this.#expireContexts(this.#now(), this.#contextRetentionMs()); + this.#scheduleExpiry(); + } + /** + * The abort marks committed records imply (spec §5.4, §5.5): every live foreground task below a live owner with + * cancellation intent, which is its nearest one (a walk down from each owner with intent does not enter another, + * whose own walk covers what is below it), and every other live task in the `on` of a `failFast` waiter one of whose + * tasks failed. A cascade from an abandoned owner passes its `restart` reason on; any other intent marks, or upgrades + * a `restart` mark, as a request. Collected first, so a request wins over a `restart` mark of the same pass. Derived + * again on every pass, as loading edges can reveal a cancelled owner. Takes the `failFast` checks once its decision + * stands. + */ + async #marks() { + const ended = /* @__PURE__ */ new Map(); + for (; ; ) { + const plan = await this.#region(() => this.#planMarks(ended), this.#reloadScopes); + if ("missing" in plan) { + for (const id of plan.missing) + ended.set(id, await this.#session.lineStorage.task(id, this.#context) ?? null); + continue; + } + for (const id of plan.checked) + this.#failFastChecks.delete(id); + return plan.marks; + } + } + /** `#marks()`' decision region; the `failFast` members it needs from Storage first, when `ended` lacks them. */ + #planMarks(ended) { + const marks = /* @__PURE__ */ new Map(); + const mark = (found, reason) => { + const record = this.#liveRecord(found.id); + const staged = marks.get(record.id); + if (staged !== void 0 && staged.reason === void 0) + return; + if (record.abortRequested && (record.abortReason === void 0 || reason !== void 0)) + return; + marks.set(record.id, { record, reason }); + }; + this.#record(INTENT_KEY); + for (const id of this.#intent) { + const owner = this.#liveRecord(id); + const restart = owner.abortReason === "restart" && !failedOutcome(owner); + for (const { node, live } of this.#walkDown(id, { enter: (below) => !this.#intent.has(below) })) { + if (live !== void 0 && !node.background) + mark(live, restart ? "restart" : void 0); + } + } + const checked = [...this.#failFastChecks]; + const missing = []; + for (const id of checked) { + const waiter = this.#liveRecord(id); + if (waiter?.state.status !== "waiting") + continue; + let failed = false; + for (const member of waiter.state.on) { + const record = this.#liveRecord(member) ?? ended.get(member); + if (record === void 0) + missing.push(member); + else if (record !== null && failedOutcome(record)) + failed = true; + } + if (!failed) + continue; + for (const member of waiter.state.on) { + const record = this.#liveRecord(member); + if (record !== void 0 && !failedOutcome(record)) + mark(record, void 0); + } + } + return missing.length > 0 ? { missing } : { marks, checked }; + } + /** Whether a reconcile pass would have work: a cascade to derive, held outcomes to check, or `failFast` waits. */ + #reconcileWork() { + return this.#cascadePending || this.#finalizeChecks.size > 0 || this.#failFastChecks.size > 0; + } + /** + * Write the terminal record of every `completing` task without live ordinary owned work. Finalizing one can free its + * owner, so this repeats over the commit's candidates until nothing changes. A held scheduler outcome gets its + * Harness cleanup here. In rounds, one decision region each: the holders the tasks of one round free are checked once, + * in the next, so a holder of many tasks that end together is walked once, not once per task. The first round takes + * the `completing` tasks to check once its decision stands. + */ + async #finalize(tx) { + const done = /* @__PURE__ */ new Set(); + const overlay = overlayOf(tx); + let round; + for (; ; ) { + const ids = round; + const plan = await this.#region(() => this.#planRound(ids ?? [...this.#finalizeChecks], overlay, done), this.#reloadScopes); + if (ids === void 0) + this.#finalizeChecks.clear(); + for (const { record, terminal } of plan.finalized) { + tx.setTask(terminal); + overlay.tasks.set(record.id, terminal); + done.add(record.id); + const outcome = record.state.outcome; + if (outcome.status === "faulted" || outcome.status === "orphaned") { + await this.#settleOutcome(tx, record, outcome); + } + } + if (plan.next.length === 0) + return; + round = plan.next; + } + } + /** + * One finalize round's decision: which of `ids` are `completing` without live ordinary owned work below them, in the + * commit's candidates with the round's own finalizations applied in order, and the holders above them to check next. + * Tasks finalized have no live work below them, so walks down skip them. It applies the round's terminal records to + * `overlay` while it decides and takes them back before it returns, so a run again starts from the same candidates. + */ + #planRound(ids, overlay, finalized) { + const tasks = overlay.tasks; + const round = /* @__PURE__ */ new Set(); + const done = { has: (id) => round.has(id) || finalized.has(id) }; + const ended = []; + const next = /* @__PURE__ */ new Set(); + try { + for (const id of ids) { + const staged = tasks.get(id); + const record = staged ?? this.#liveRecord(id); + if (record?.state.status !== "completing" || done.has(id)) + continue; + if (this.#hasOwnedLive(id, overlay, done)) + continue; + const terminal = withState(record, { status: "terminal", outcome: record.state.outcome }); + tasks.set(id, terminal); + round.add(id); + ended.push({ record, terminal, staged: staged !== void 0 }); + const above = this.#nearestLiveAbove(record, overlay); + if (above?.state.status === "completing") + next.add(above.id); + } + } finally { + for (const { record, staged } of ended) { + if (staged) + tasks.set(record.id, record); + else + tasks.delete(record.id); + } + } + return { finalized: ended, next: [...next] }; + } + /** + * Load the owner chains of the tasks in `#unloaded` and, with `queued`, of every conversation with queued inputs, + * on the Session line; returns the latter. Reads through the line's Storage, so it may run inside a commit callback. + */ + async #loadScopes(queued) { + for (const id of [...this.#unloaded]) { + if (this.#unloaded.has(id)) + await this.#loadChain({ task: id }); + } + if (!queued) + return []; + const submissions = await scanAll((cursor) => this.#session.lineStorage.scanSubmissions({ status: "queued" }, SCAN_PAGE_SIZE3, cursor, this.#context)); + const inputs = submissions.filter((submission2) => submission2.type === "input"); + const conversations = [...new Set(inputs.map((submission2) => submission2.conversationId))]; + for (const id of conversations) + await this.#loadChain({ conversation: id }); + return conversations; + } + /** + * Load the owner edges and task nodes from `start` up to its ownerless root. A known task outside `#unloaded` ends the + * walk, as its chain is loaded; the `#unloaded` tasks passed have a loaded chain afterwards. Loading a node can reveal + * a cancelled owner, so it makes the next reconcile derive cascade marks. The nodes it installs stay until the job's + * commit is published (`#pin()`). + */ + async #loadChain(start, overlay) { + const passed = []; + let installed = false; + let at = start; + try { + while (at !== void 0) { + if ("task" in at) { + const id = at.task; + let node = this.#live.get(id) ?? this.#settled.get(id); + if (node === void 0 && overlay?.tasks.has(id) === true) + break; + if (node !== void 0 && !this.#unloaded.has(id)) + break; + if (node === void 0) { + const record = await this.#session.lineStorage.task(id, this.#context); + if (record === void 0) + return; + node = nodeOf(record); + if (record.state.status === "terminal") { + this.#settle(id, node); + this.#pin(id); + installed = true; + } + this.#scheduleReconcile(); + } + passed.push(id); + at = parentOf(node); + } else { + let edge = this.#edge(at.conversation, overlay); + if (edge === void 0) { + const record = await this.#session.lineStorage.conversation(at.conversation, this.#context); + edge = record?.owner?.taskId ?? null; + this.#setEdge(at.conversation, edge); + this.#pin(conversationNode(at.conversation)); + installed = true; + this.#scheduleReconcile(); + } + at = edge === null ? void 0 : { task: edge }; + } + } + } finally { + if (passed.some((id) => this.#live.has(id)) || installed && this.#liveUnloaded()) { + this.#unknownsChanged = true; + this.#scheduleReconcile(); + } + } + for (const id of passed) + this.#unloaded.delete(id); + } + /** Whether a live task's chain is not loaded yet: one `#idle()` counts as inside every scope it may be in. */ + #liveUnloaded() { + for (const id of this.#unloaded) + if (this.#live.has(id)) + return true; + return false; + } + #setEdge(conversationId, owner) { + this.#edges.set(conversationId, owner); + this.#relist(conversationNode(conversationId)); + this.#dropIfUnneeded(conversationNode(conversationId)); + } + /** Know a terminal task a chain load read, so walks pass through it; its own chain is not loaded yet. */ + #settle(id, node) { + this.#settled.set(id, node); + this.#unloaded.add(id); + this.#relist(id); + this.#dropIfUnneeded(id); + } + /** Queue an ended task or a conversation for the sweep when nothing is listed below it. */ + #dropIfUnneeded(node) { + if (typeof node !== "string" && this.#live.has(node)) + return; + if (this.#below.has(node)) + return; + this.#dropQueue.add(node); + this.#scheduleSweep(); + } + /** + * Sweep in a job of its own on the Session line, after the operation underway: a rejected commit, one with nothing to + * write, and a read publish nothing, so the sweep at the end of `#observe` would not run for what they loaded. + */ + #scheduleSweep() { + if (this.#sweepScheduled || this.#closing) + return; + this.#sweepScheduled = true; + this.#session.readOnLine(async () => { + this.#sweepScheduled = false; + this.#sweep(); + }).catch(() => { + }); + } + /** + * Drop the queued nodes that are still unneeded: not live, nothing listed below. Runs once a publication is fully + * tracked, and as a job of its own on the line, never inside an operation: work a commit creates below a chain its + * own callback loaded keeps that chain. A flat loop: unlisting already queued every node on the way up that lost its + * last listed node. + */ + #sweep() { + for (const node of this.#dropQueue) { + if (this.#below.has(node) || this.#pins.has(node)) + continue; + if (typeof node === "string") { + this.#edges.delete(conversationOf(node)); + this.#roots.delete(conversationOf(node)); + } else if (!this.#live.has(node)) { + this.#settled.delete(node); + this.#unloaded.delete(node); + } + } + this.#dropQueue.clear(); + } + /** + * Run `decide`, a decision region (group commit §6): synchronous, reading scheduler memory and recording the keys it + * reads, staging nothing and changing no scheduler state. When a key it read has an unpublished writer, its result, + * or its error, is dropped: once that writer is published, `refresh` reloads what the region needs and it runs + * again. A waiting job holds the line, so unpublished writes only shrink, and the runs converge. What a region read + * that had no unpublished writer gets none for the rest of the job, so its result stands while the job applies it. + */ + async #region(decide, refresh) { + for (; ; ) { + const keys = /* @__PURE__ */ new Set(); + this.#reading = keys; + let result; + try { + result = { value: decide() }; + } catch (error) { + result = { error }; + } finally { + this.#reading = void 0; + } + const gates = []; + for (const key of keys) { + const gate = this.#session.touch(key); + if (gate !== void 0) + gates.push(gate); + } + if (gates.length === 0) { + if ("error" in result) + throw result.error; + return result.value; + } + await Promise.all(gates); + await refresh?.(); + } + } + /** Load the chains a region walks, again after it waited: what was published meanwhile may list new work. */ + #reloadScopes = () => this.#loadScopes(false); + /** In a decision region, note that it reads `key`; anywhere else, nothing. */ + #record(key) { + this.#reading?.add(key); + } + /** A live task's committed record, read by a decision region. */ + #liveRecord(id) { + this.#record(taskKey(id)); + return this.#live.get(id); + } + /** + * Keep `node`, which a chain load of the job on the line installed, until that job's commit is published or fails: + * work it creates below the node needs it, and the sweep would drop it as unneeded before then. + */ + #pin(node) { + this.#pins.set(node, (this.#pins.get(node) ?? 0) + 1); + this.#session.onJobEnd(() => { + const count = this.#pins.get(node) - 1; + if (count > 0) { + this.#pins.set(node, count); + return; + } + this.#pins.delete(node); + this.#dropIfUnneeded(node); + }); + } + /** + * Bring the scheduler's sets in line with a task's committed record. A task that ended may free the nearest live task + * above it: one holding its outcome finalizes, an abort-marked one runs its abort handler; its waiters may run. + */ + #track(previous, record) { + const id = record.id; + const invocation = this.#invocations.get(id); + if (invocation !== void 0) + invocation.record = record; + if (record.state.status === "terminal") { + if (previous !== void 0 && !this.#closing) + this.#countActive(-1, record.kind); + this.#resumed.delete(id); + } else if (previous === void 0 && !this.#closing) { + this.#countActive(1, record.kind); + } + if (previous?.state.status === "waiting") + for (const on of previous.state.on) + deleteFrom(this.#waiters, on, id); + if (record.state.status === "terminal") { + const node = nodeOf(record); + this.#live.delete(id); + this.#sleeping.delete(id); + this.#intent.delete(id); + this.#runnable.delete(id); + this.#finalizeChecks.delete(id); + this.#settled.set(id, node); + this.#release(node); + for (const waiter of this.#waiters.get(id) ?? []) + this.#runnable.add(waiter); + this.#relist(id); + this.#dropIfUnneeded(id); + return; + } + this.#live.set(id, record); + if (previous === void 0) { + if (!this.#known(parentNode(record))) + this.#unloaded.add(id); + this.#relist(id); + } + if (cancellationIntent(record)) + this.#intent.add(id); + else + this.#intent.delete(id); + if (record.state.status === "completing") { + this.#runnable.delete(id); + this.#finalizeChecks.add(id); + } else { + this.#runnable.add(id); + } + if (record.state.status === "waiting") + for (const on of record.state.on) + addTo(this.#waiters, on, id); + if (record.state.status === "sleeping") + this.#sleeping.add(id); + else + this.#sleeping.delete(id); + } + /** The nearest live task above ended ordinary work may be free now: one holding its outcome, or an aborting one. */ + #release(node, overlay) { + const above = this.#nearestLiveAbove(node, overlay); + if (above?.state.status === "completing") + this.#finalizeChecks.add(above.id); + else if (above?.abortRequested === true) + this.#runnable.add(above.id); + return above; + } + /** + * List `start` under its parent node in `#below` while it is a live task or has nodes listed below it, and unlist it + * otherwise; then its parent, as far up as that changes. So `#below` holds exactly the tree around live work; a + * parent that loses its last listed node is queued for the sweep. A loop, as ownership chains can be deeper than the + * call stack. + */ + #relist(start) { + let node = start; + for (; ; ) { + const needed = typeof node !== "string" && this.#live.has(node) || this.#below.has(node); + let parent; + if (typeof node === "string") { + parent = this.#edges.get(conversationOf(node)); + } else { + const known = this.#live.get(node) ?? this.#settled.get(node); + if (known === void 0) + return; + parent = parentNode(known); + } + if (parent === void 0) + return; + if (parent === null) { + const root2 = conversationOf(node); + if (needed) + this.#roots.add(root2); + else + this.#roots.delete(root2); + return; + } + if (this.#below.get(parent)?.has(node) === true === needed) + return; + if (needed) { + const first = !this.#below.has(parent); + addTo(this.#below, parent, node); + if (!first) + return; + } else { + deleteFrom(this.#below, parent, node); + if (this.#below.has(parent)) + return; + this.#dropIfUnneeded(parent); + } + node = parent; + } + } + /** + * The nearest live task above ended ordinary work `node`, in the overlay's view: the one that work held, if any. Work + * at or below a background task holds nothing above it. + */ + #nearestLiveAbove(node, overlay) { + this.#record(INTENT_KEY); + for (let current = node; !current.background; ) { + const owner = current.owner ?? this.#edge(current.conversationId, overlay); + if (owner === void 0 || owner === null) + return void 0; + const record = overlay?.tasks.get(owner) ?? this.#liveRecord(owner); + if (record !== void 0 && record.state.status !== "terminal") + return record; + const above = record ?? this.#settled.get(owner); + if (above === void 0) + return void 0; + current = above; + } + return void 0; + } + /** Whether the chain above `node` is loaded, without walking: known tasks outside `#unloaded` have a loaded chain. */ + #known(node) { + if (typeof node !== "string") { + return (this.#live.has(node) || this.#settled.has(node)) && !this.#unloaded.has(node); + } + const owner = this.#edges.get(conversationOf(node)); + return owner === null || owner !== void 0 && this.#known(owner); + } + /** Owner task of a conversation, `null` when ownerless, `undefined` while not loaded. */ + #edge(id, overlay) { + if (overlay?.edges.has(id) === true) + return overlay.edges.get(id); + this.#record(conversationKey(id)); + return this.#edges.get(id); + } + #node(id, overlay) { + return overlay?.tasks.get(id) ?? this.#live.get(id) ?? this.#settled.get(id); + } + /** Walk up from `start`: owner tasks and conversations, ending at an ownerless root or an edge not loaded yet. */ + *#above(start, overlay) { + let at = start; + while (at !== void 0) { + if ("task" in at) { + const node = this.#node(at.task, overlay); + if (node === void 0) { + yield { unknown: true }; + return; + } + yield { task: at.task, node }; + at = parentOf(node); + } else { + yield { conversation: at.conversation }; + const edge = this.#edge(at.conversation, overlay); + if (edge === void 0) { + yield { unknown: true }; + return; + } + at = edge === null ? void 0 : { task: edge }; + } + } + } + /** + * The tasks below `start` that a walk down reaches: those `#below` lists and, under an overlay, the commit's new tasks, + * with its candidates in place of committed records. Background tasks are reached but not entered, unless + * `crossBackground`, and neither are the tasks `enter` rejects. A loop, as chains can be deeper than the call stack. + */ + *#walkDown(start, options = {}) { + const { overlay, crossBackground = false, enter } = options; + const links = overlay === void 0 ? void 0 : this.#links(overlay); + const pending = [start]; + for (let at = pending.pop(); at !== void 0; at = pending.pop()) { + this.#record(ownedKey(at)); + for (const children of [this.#below.get(at), links?.get(at)]) { + for (const child of children ?? []) { + if (typeof child === "string") { + pending.push(child); + continue; + } + const record = overlay?.tasks.get(child) ?? this.#live.get(child); + const node = record ?? this.#settled.get(child); + if (node === void 0) + continue; + const live = record !== void 0 && record.state.status !== "terminal" ? record : void 0; + yield { id: child, node, live }; + if ((crossBackground || !node.background) && (enter === void 0 || enter(child))) + pending.push(child); + } + } + } + } + /** + * The links walks down need for the commit's new tasks: each one under its parent node, then each node above it that + * `#below` does not list yet, until one it lists, a live task (always listed), or the chain's end. + */ + #links(overlay) { + if (overlay.links !== void 0) + return overlay.links; + const links = /* @__PURE__ */ new Map(); + for (const record of overlay.tasks.values()) { + if (this.#live.has(record.id) || this.#settled.has(record.id)) + continue; + let child = record.id; + let parent = parentNode(record); + while (parent !== void 0 && parent !== null && links.get(parent)?.has(child) !== true) { + addTo(links, parent, child); + if (typeof parent !== "string" && (this.#live.has(parent) || overlay.tasks.has(parent))) + break; + let above; + if (typeof parent === "string") { + above = this.#edge(conversationOf(parent), overlay); + } else { + const fields = this.#settled.get(parent); + above = fields === void 0 ? void 0 : parentNode(fields); + } + if (above !== void 0 && above !== null && this.#below.get(above)?.has(parent) === true) + break; + child = parent; + parent = above; + } + } + overlay.links = links; + return links; + } + /** + * The live ordinary owned work of `owner` (spec §5.5): the live non-background tasks below it that a walk down reaches + * without entering a background task; with `first`, only the first. Candidates in `overlay` replace committed records; + * the walk does not enter tasks in `done`, which have none. Owner chains must be loaded. + */ + #ownedLive(owner, options = {}) { + const { overlay, first = false, done } = options; + const found = []; + const walk = this.#walkDown(owner, { + ...overlay === void 0 ? {} : { overlay }, + ...done === void 0 ? {} : { enter: (id) => !done.has(id) } + }); + for (const { id, node, live } of walk) { + if (live === void 0 || node.background) + continue; + found.push(id); + if (first) + break; + } + return found; + } + #hasOwnedLive(owner, overlay, done) { + const options = { + first: true, + ...overlay === void 0 ? {} : { overlay }, + ...done === void 0 ? {} : { done } + }; + return this.#ownedLive(owner, options).length > 0; + } + /** + * Whether ordinary traversal from `scope` reaches `start`: walking up reaches the scope's conversation, or an + * ownerless one for `roots`, without crossing a background owner, unless `crossBackground`. `undefined` while an + * edge is not loaded. + */ + #inScope(start, scope, crossBackground = false) { + this.#record(INTENT_KEY); + for (const step of this.#above(start)) { + if ("unknown" in step) + return void 0; + if ("conversation" in step) { + if ("conversation" in scope && step.conversation === scope.conversation) + return true; + } else if (step.node.background && !crossBackground) { + return false; + } + } + return "roots" in scope; + } + /** + * Whether a live owner's cancellation intent reaches `start`: walking up finds an owner with intent before a + * background owner without it. Terminal owners never cascade (spec §5.4). + */ + #belowCancelled(start) { + return this.#cancellingOwner(start) !== void 0; + } + /** The nearest live owner above `start` whose cancellation intent reaches it, if any; see `#belowCancelled`. */ + #cancellingOwner(start) { + this.#record(INTENT_KEY); + if (this.#intent.size === 0) + return void 0; + for (const step of this.#above(start)) { + if ("unknown" in step) + return void 0; + if (!("task" in step)) + continue; + const live = this.#live.get(step.task); + if (live !== void 0 && cancellationIntent(live)) + return live; + if (step.node.background) + return void 0; + } + return void 0; + } + /** + * Count a task in or out of `pi.durable.tasks.active`. Commit observers count at a commit's publication, which must not + * throw, so a throwing adapter is reported. + */ + #countActive(delta, kind) { + try { + this.#activeMetric.add(delta, { task: kind }); + } catch (error) { + this.#report(error, TELEMETRY2); + } + } + /** Fail the Session, which reports it, for a throw in one of the scheduler's own commits; ignored once closing. */ + #failSession(error, context) { + if (!this.#closing) + this.#session.fail(error, context); + } + /** Close listener: runs synchronously once admission is sealed, before `join()`, or when the Session fails. */ + #seal() { + for (const record of this.#live.values()) + this.#countActive(-1, record.kind); + this.#resumed.clear(); + this.#closing = true; + try { + this.#unsubscribeRegistry(); + } catch (error2) { + this.#report(error2, REGISTRY); + } + const error = closedError(this.#session); + this.#taskWaiters.rejectAll(error); + this.#idleWaiters.rejectAll(error); + this.#contexts.clear(); + this.#idleOrder.clear(); + this.#expiry?.disarm(); + this.#expiry = void 0; + this.#timer?.disarm(); + this.#timer = void 0; + } + /** + * Recompute the wake in a microtask the line never waits for, with no order relative to host commit listeners (one + * already queued serves later changes too): arm the built-in timer, or tell `onWake` of a change. + */ + #scheduleWake() { + if (this.#wakeScheduled || this.#closing || this.#readOnly) + return; + this.#wakeScheduled = true; + queueMicrotask(() => { + this.#wakeScheduled = false; + if (this.#closing) + return; + const wake = this.wake(); + if (this.#onWake === void 0) { + this.#armTimer(this.#enabled ? wake.at : void 0); + return; + } + const last = this.#lastWake; + if (last !== void 0 && last.active === wake.active && last.at === wake.at) + return; + try { + this.#onWake(wake); + this.#lastWake = wake; + } catch (error) { + this.#report(error, ON_WAKE); + } + }); + } + /** + * Keep the timer for the earliest sleep. It keeps the process alive, unlike the context expiry timer: a process with a + * sleeping task has work left, such as a retry backoff. Rechecks the clock when it fires, so a wake before `until` + * only arms it again. + */ + #armTimer(at) { + if (this.#timer !== void 0 && this.#timer.at === at) + return; + this.#timer?.disarm(); + this.#timer = void 0; + if (at === void 0 || this.#closing) + return; + const disarm = this.#timers.foreground(() => { + this.#timer = void 0; + this.#wakeDue(); + this.#kick(); + }, at - this.#now()); + this.#timer = { at, disarm }; + } + /** Make every sleeping task whose time has come a reservation candidate. */ + #wakeDue() { + for (const id of this.#sleeping) { + const record = this.#live.get(id); + if (record !== void 0 && !this.#notDue(record)) + this.#runnable.add(id); + } + } + /** + * The report info of a task's failure, with the extension that installed its definition: the code that failed is + * that extension's. Built-in tasks have none. + */ + /** What a report about `task`'s own code says of it: the task, and the extension that installed its definition. */ + #aboutTask(task) { + let extension; + if ("extension" in task) + extension = task.extension; + else { + try { + const snapshot = this.#registry.snapshot(); + extension = installerOf(snapshot, snapshot.task(task.kind)); + } catch { + } + } + return { task: reportTask(task), ...optionalExtension(extension) }; + } + /** `settings.restartLimit`, or the default when the host's settings throw. */ + #restartLimit() { + try { + return this.#settings().restartLimit; + } catch (error) { + this.#report(error, SETTINGS); + return DEFAULT_RESTART_LIMIT; + } + } + #notifyBlocked() { + for (const listener of [...this.#blockedListeners]) { + try { + listener(); + } catch (error) { + this.#report(error, { kind: "listener_error" }); + } + } + } + /** A sleep whose `until` the clock has not reached; an abort mark ends a sleep. */ + #notDue(record) { + const state = record.state; + return state.status === "sleeping" && !record.abortRequested && state.until > this.#now(); + } + #kick() { + this.#scheduleWake(); + this.#dirty = true; + if (this.#draining || !this.#enabled || this.#closing) + return; + this.#draining = true; + queueMicrotask(() => void this.#drain()); + } + async #drain() { + try { + while (this.#dirty && this.#enabled && !this.#closing) { + this.#dirty = false; + for (const reservation of await this.#reserve()) + this.#start(reservation); + } + } catch (error) { + this.#failSession(error); + } finally { + this.#draining = false; + if (this.#dirty) + this.#kick(); + } + } + /** + * Reserve every eligible task in one commit; orphan abort-marked tasks no definition can take, unless abandoned after + * a restart. The first pass after open only abort-marks the abandoned tasks, so later passes see the marks. + */ + async #reserve() { + const reservations = []; + const faulted = []; + let abandoned = []; + try { + await this.#session.commitInternal(async (tx) => { + if (!this.#enabled || this.#closing) + return; + if (this.#abandoned.size > 0) { + const ids = [...this.#abandoned]; + const unmarked = await this.#region(() => ids.map((id) => this.#liveRecord(id)).filter((record) => record !== void 0 && !record.abortRequested)); + for (const record of unmarked) + tx.setTask(withAbortMark(record, "restart")); + abandoned = ids; + return; + } + await this.#loadScopes(false); + this.#wakeDue(); + const plan = await this.#region(() => this.#planReservation(), this.#reloadScopes); + for (const id of plan.dropped) + this.#runnable.delete(id); + if (plan.cascade) { + this.#cascadePending = true; + this.#scheduleReconcile(); + } + let snapshot; + for (const record of plan.ready) { + const id = record.id; + const mode = record.abortRequested ? "abort" : "run"; + const cutOff = record.restarts !== void 0 && record.restarts >= this.#restartLimit(); + if (cutOff && (mode === "abort" || !this.#limitsRestarts(record.kind))) { + const message = mode === "abort" ? `Abort handler of task ${id} (${record.kind}) was cut off ${record.restarts} times` : `Task ${id} (${record.kind}) was cut off ${record.restarts} times without progress`; + await this.#terminate(tx, record, { + status: "faulted", + error: { message, detail: { reason: "restart_limit" } } + }); + faulted.push({ record, message }); + continue; + } + snapshot ??= this.#registry.snapshot(); + const resolution = this.#resolve(record, snapshot); + if (resolution.kind === "blocked") { + if (mode === "abort" && record.abortReason === void 0) { + await this.#terminate(tx, record, { status: "orphaned", reason: resolution.reason }); + } + continue; + } + if (resolution.record !== record || record.state.status !== "running") { + tx.setTask(withState(resolution.record, { + status: "running", + checkpoint: resolution.record.state.checkpoint + })); + } + this.#runnable.delete(id); + const invocation = this.#createInvocation(record, mode, installerOf(snapshot, resolution.task)); + reservations.push({ invocation, task: resolution.task, snapshot }); + } + }, this.#context); + } catch (error) { + for (const { invocation } of reservations) { + this.#invocations.delete(invocation.taskId); + this.#runnable.add(invocation.taskId); + invocation.finish(); + } + throw error; + } + for (const { record, message } of faulted) { + this.#report(new Error(message), { + kind: "task_faulted", + ...this.#aboutTask(recordTask(record)), + reason: "restart_limit" + }); + } + for (const id of abandoned) + this.#abandoned.delete(id); + if (abandoned.length > 0) + this.#dirty = true; + return reservations; + } + /** + * A reservation pass's decision region: the candidates it drops (gone, holding, waiting, asleep, or with an + * invocation), whether one waits for a cascade, and the runnable records to resolve. Iterating the candidates is not + * keyed: a pass that misses a task a pending commit makes runnable only delays it, as does skipping one with an + * invocation without reading its record, or a waiter's check whether what it waits on is still live. + */ + #planReservation() { + const dropped = []; + const ready = []; + let cascade = false; + for (const id of this.#runnable) { + if (this.#invocations.has(id)) { + dropped.push(id); + continue; + } + const record = this.#liveRecord(id); + if (record === void 0 || record.state.status === "completing" || this.#waitingOn(record, true).length > 0 || this.#notDue(record)) { + dropped.push(id); + continue; + } + if (!record.abortRequested && !record.background && this.#belowCancelled(parentOf(record))) { + cascade = true; + continue; + } + ready.push(record); + } + return { dropped, cascade, ready }; + } + /** + * Live tasks a task waits for before its next invocation: its live ordinary owned work when abort-marked, since + * abort runs bottom-up, otherwise the live part of the `on` of a wait. + */ + #waitingOn(record, first = false) { + if (record.abortRequested) + return this.#ownedLive(record.id, { first }); + if (record.state.status !== "waiting") + return []; + if (!first) + return record.state.on.filter((id) => this.#live.has(id)); + const live = record.state.on.find((id) => this.#live.has(id)); + return live === void 0 ? [] : [live]; + } + /** Resolve the record's definition by kind, migrating an older stored version. */ + #resolve(record, snapshot) { + const fit = this.#fit(record, snapshot.task(record.kind)); + if ("reason" in fit) + return { kind: "blocked", reason: fit.reason }; + if (!fit.migrates) + return { kind: "ready", task: fit.task, record }; + const definition = erased(fit.task); + try { + if (definition.migrate === void 0) + throw missingMigration(record, definition); + const migrated = definition.migrate(record.input, record.state.checkpoint, record.version); + const state = { ...record.state, checkpoint: copyJson(migrated.checkpoint) }; + const migratedRecord = { ...record, version: definition.version, input: copyJson(migrated.input), state }; + return { kind: "ready", task: fit.task, record: migratedRecord }; + } catch (error) { + this.#failedMigrations.set(record.id, { task: fit.task, error }); + queueMicrotask(() => this.#notifyBlocked()); + this.#scheduleWake(); + const extension = installerOf(snapshot, fit.task); + this.#report(error, { kind: "migration_failed", ...this.#aboutTask({ ...recordTask(record), extension }) }); + return { kind: "blocked", reason: "migration_failed" }; + } + } + #fit(record, task) { + if (task === void 0) + return { reason: "missing_task" }; + const version = task.definition.version; + if (version === record.version) + return { task, migrates: false }; + if (version < record.version) + return { reason: "task_too_old" }; + const failed = this.#failedMigrations.get(record.id); + if (failed?.task === task) + return { reason: "migration_failed", error: failed.error }; + return { task, migrates: true }; + } + /** Sizes of the ownership indexes, for tests that bound the scheduler's memory. */ + get indexSizes() { + let below = 0; + for (const children of this.#below.values()) + below += children.size; + return { + live: this.#live.size, + settled: this.#settled.size, + edges: this.#edges.size, + below, + roots: this.#roots.size, + unloaded: this.#unloaded.size, + dropQueue: this.#dropQueue.size, + pins: this.#pins.size + }; + } + /** + * The kept contexts' idle state, for tests (a diagnostic, computed on demand): how many are kept and how many are + * idle, how many disagree with a full recompute (an idle period without `#idle()`, or `#idle()` without one; an + * expiry order that differs from the idle periods, or is not sorted), and how many idle checks covered every scope. + */ + get idleState() { + let mismatches = 0; + let idle = 0; + for (const [conversationId, kept] of this.#contexts) { + if (kept.idleSince !== void 0) + idle++; + if (kept.idleSince !== void 0 !== this.#idle(conversationId)) + mismatches++; + if (this.#idleOrder.get(conversationId) !== kept.idleSince) + mismatches++; + } + let previous = Number.NEGATIVE_INFINITY; + for (const [conversationId, idleSince] of this.#idleOrder) { + if (idleSince < previous || this.#contexts.get(conversationId)?.idleSince !== idleSince) + mismatches++; + previous = idleSince; + } + return { + contexts: this.#contexts.size, + idleContexts: idle, + idleMismatches: mismatches, + fullIdleChecks: this.#fullIdleChecks + }; + } + /** + * Scheduling state and every live task with its derived state, read on the Session line. Runs no task code: a + * pending migration shows as `ready` with `migrates`, and only a migration the scheduler already tried, or one that + * cannot exist, shows as failed. + */ + async inspect(snapshot) { + await this.#published(); + await this.#loadScopes(false); + const tasks = []; + for (const record of this.#live.values()) + tasks.push({ record, state: this.#inspectTask(record, snapshot) }); + const scheduling = this.#closing ? "closing" : this.#enabled ? "running" : "paused"; + return { scheduling, tasks }; + } + #inspectTask(record, snapshot) { + if (this.#invocations.has(record.id)) + return { kind: "running" }; + if (record.state.status === "completing") + return { kind: "completing" }; + const on = this.#waitingOn(record); + if (on.length > 0) + return { kind: "waiting", on }; + if (this.#notDue(record)) + return { kind: "sleeping", until: record.state.until }; + const fit = this.#fit(record, snapshot.task(record.kind)); + if ("reason" in fit) + return { kind: "blocked", ...fit }; + if (fit.migrates && fit.task.definition.migrate === void 0) { + return { kind: "blocked", reason: "migration_failed", error: missingMigration(record, erased(fit.task)) }; + } + return { kind: "pending", migrates: fit.migrates }; + } + #createInvocation(record, mode, extension) { + const controller = new AbortController(); + const { promise: done, resolve: finish } = Promise.withResolvers(); + const invocation = { + taskId: record.id, + kind: record.kind, + extension, + conversationId: record.conversationId, + mode, + controller, + links: record.startedAt === void 0 ? record.links : void 0, + context: withAbortSignal(controller.signal, this.#context), + record, + watches: /* @__PURE__ */ new Set(), + timers: /* @__PURE__ */ new Set(), + ended: false, + done, + finish: () => finish() + }; + this.#invocations.set(record.id, invocation); + return invocation; + } + #start(reservation) { + const invocation = reservation.invocation; + void (async () => { + try { + if (invocation.mode === "run") + await this.#run(reservation); + else + await this.#runAbort(reservation); + } catch (error) { + this.#report(error, { kind: "task_error", ...this.#aboutTask(invocation) }, invocation.context); + } finally { + this.#end(invocation); + invocation.record = void 0; + invocation.links = void 0; + invocation.fault = void 0; + invocation.finish(); + this.#kick(); + } + })(); + } + /** Run phase handlers, each preceded by a step that decides on the line whether the invocation continues. */ + async #run(reservation) { + const invocation = reservation.invocation; + const state = { task: reservation.task, snapshot: reservation.snapshot, reported: void 0 }; + const phase = { snapshot: () => state.snapshot, task: () => state.task, agent: void 0 }; + const runtime = this.#runtime(invocation, phase); + const progress = (tx, current2) => this.#progress(tx, current2, state, invocation.extension, invocation.context); + let current = await this.#step(invocation, (task) => this.#rules(task, void 0), progress); + while (current !== void 0 && !this.#closing) { + const task = current; + current = await this.#phase(invocation, task, async () => { + const checkpoint = task.state.checkpoint; + phase.agent = void 0; + let previous; + try { + await erased(state.task).phases[checkpoint.phase](task, runtime, invocation.context); + previous = { checkpoint }; + } catch (error) { + previous = { checkpoint, failure: { error } }; + } + return this.#step(invocation, (next) => this.#rules(next, previous), progress); + }); + } + } + /** A phase owns its decision commit, not the next handler, a durable wait, or recovery. */ + #phase(invocation, task, run2) { + const parent = task.trace === void 0 ? this.#telemetry : this.#telemetry.propagation?.extract(task.trace) ?? this.#telemetry; + const checkpoint = task.state.checkpoint; + const attempt = checkpoint.attempt; + const resumed = this.#resumed.delete(task.id); + return createTypedSpanStarter(parent, [DURABLE_TELEMETRY_SCHEMA])("pi.durable.phase", { + "task.id": task.id, + "task.name": task.kind, + phase: checkpoint.phase, + kind: invocation.mode, + resumed, + ...attempt === void 0 ? {} : { attempt } + }, async (span) => { + if (span.propagation !== void 0 && invocation.links !== void 0) { + for (const carrier of invocation.links) + span.addLink({ context: span.propagation.extract(carrier) }); + } + const previous = invocation.context; + invocation.context = withTelemetry(span, previous); + const started = performance.now(); + try { + return await run2(); + } finally { + invocation.context = previous; + const state = invocation.record.state; + const terminal = invocation.fault ?? (state.status === "terminal" || state.status === "completing" ? state.outcome : void 0); + const outcome = terminal !== void 0 ? terminal.status === "completed" ? "done" : terminal.status === "aborted" ? "aborted" : "faulted" : state.status === "waiting" || state.status === "sleeping" ? state.status : invocation.ended || this.#closing ? "aborted" : `next=${state.checkpoint.phase}`; + span.setAttributes({ outcome }); + if (terminal?.status === "faulted" || terminal?.status === "failed" || terminal?.status === "orphaned") { + const message = terminal.status === "orphaned" ? terminal.reason : terminal.error.message; + span.setStatus({ + status: "error", + error: { name: `task_${terminal.status}`, message: ownedSlice2(message, 0, 500) } + }); + } else if (outcome === "aborted") { + span.setStatus({ status: "ok" }); + } + this.#durationMetric.record(performance.now() - started, { task: task.kind, phase: checkpoint.phase, outcome }, span); + } + }); + } + /** + * Precedence rules for a run invocation, a decision region of its step. Rules 1 (terminal, `completing`, `waiting`, or + * `sleeping`) and 2 (closing) are applied by `#step`. Progress (rule 5) is decided by `#progress()` once the decision + * stands, as it takes a registry snapshot. + */ + #rules(current, previous) { + if (current.abortRequested) + return END; + if (!current.background && this.#belowCancelled(parentOf(current))) + return CASCADE; + if (previous === void 0) + return CONTINUE; + if (previous.failure !== void 0) + return { kind: "fault", error: previous.failure.error }; + if (jsonEqual(current.state.checkpoint, previous.checkpoint)) { + const message = `Task ${current.kind} phase ${previous.checkpoint.phase} returned without durable progress`; + return { kind: "fault", error: new Error(message) }; + } + return PROGRESS; + } + /** + * Rule 5, progress: refresh the snapshot; hand over to a replacement definition that can take the task, writing it + * back as `pending`, or keep running under the old one, reported once per resolved definition. Returns whether the + * invocation continues. + */ + #progress(tx, current, state, extension, context) { + state.snapshot = this.#registry.snapshot(); + const next = state.snapshot.task(current.kind); + if (next === state.task) + return true; + if (next !== void 0 && canReserve(next, current)) { + tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint })); + return false; + } + if (state.reported === void 0 || state.reported.task !== next) { + state.reported = { task: next }; + this.#report(new Error(`Task ${current.id} keeps running under its old ${current.kind} definition`), { + kind: "task_outdated", + ...this.#aboutTask({ ...recordTask(current), extension }), + reason: outdatedReason(next, current) + }, context); + } + return true; + } + /** Run the abort handler once; rules 1, 2, and 4 apply, and returning without an outcome faults. */ + async #runAbort(reservation) { + const invocation = reservation.invocation; + const current = this.#live.get(invocation.taskId); + if (current === void 0 || this.#closing) + return; + await this.#phase(invocation, current, async () => { + let failure; + try { + const phase = { + snapshot: () => reservation.snapshot, + task: () => reservation.task, + agent: void 0 + }; + const runtime = this.#runtime(invocation, phase); + await erased(reservation.task).abort(current, runtime, invocation.context); + } catch (error) { + failure = { error }; + } + const message = `Abort handler of task ${invocation.taskId} returned without a terminal outcome`; + await this.#step(invocation, () => ({ kind: "fault", error: failure?.error ?? new Error(message) })); + }); + } + /** + * One decision on the Session line, before a phase: a decision region (`#region()`) that reads the task's record. A + * task that is no longer running (rule 1: terminal, `completing`, `waiting`, or `sleeping`) or a closing Harness (rule + * 2) ends the invocation without a write; otherwise `plan` decides, and a decision that stands is applied: the + * invocation continues, with `progress` for rule 5, or ends, with a fault written. Ending happens inside the callback, + * before a fault's Harness cleanup. A rejected step, such as admission after close, also ends the invocation. Its + * outcome goes to the invocation only, so it is a scheduler commit (`commitInternal()`). + */ + async #step(invocation, plan, progress) { + let fault; + try { + const continued = await this.#session.commitInternal(async (tx) => { + const decided = await this.#region(() => { + const found = this.#liveRecord(invocation.taskId); + if (found?.state.status !== "running" || this.#closing) + return { plan: END }; + const current2 = found; + return { current: current2, plan: plan(current2) }; + }); + const { current } = decided; + const step = decided.plan; + if (step.kind === "continue") + return current; + if (step.kind === "progress" && progress(tx, current)) + return current; + this.#end(invocation); + if (step.kind === "cascade") { + this.#cascadePending = true; + this.#scheduleReconcile(); + } + if (step.kind === "fault") { + fault = { error: step.error }; + const message = errorMessage(step.error); + invocation.fault = { status: "faulted", error: { message } }; + await this.#terminate(tx, current, invocation.fault); + } + return void 0; + }, withoutAbortSignal(invocation.context)); + if (fault !== void 0) + this.#report(fault.error, { kind: "task_faulted", ...this.#aboutTask(invocation), reason: "threw" }, invocation.context); + return continued; + } catch (error) { + this.#end(invocation); + this.#failSession(error, invocation.context); + return void 0; + } + } + /** + * Write an outcome the scheduler decided. While the task's ordinary owned work is live it holds as `completing` and + * its Harness cleanup waits for the final commit (spec §5.5, rule 4); otherwise it is terminal with its cleanup. + */ + async #terminate(tx, record, outcome) { + await this.#loadScopes(false); + if (await this.#region(() => this.#hasOwnedLive(record.id, overlayOf(tx)), this.#reloadScopes)) { + tx.setTask(withState(record, { status: "completing", outcome })); + return; + } + tx.setTask(withState(record, { status: "terminal", outcome })); + await this.#settleOutcome(tx, record, outcome); + } + /** + * Replace a running task's state with what it committed. A terminal state holds as `completing` while ordinary owned + * work is live, judged on the commit's candidates, so work the same commit creates below the task counts. A wait is + * validated first. + */ + async #commitState(tx, invocation, current, next) { + if (next.status === "waiting") + await this.#validateWait(tx, invocation, current, next.on, next.policy); + if (next.status === "sleeping") + validateSleep(invocation, current.id, next.until); + if (next.status === "terminal") { + const overlay = overlayOf(tx); + await this.#loadScopes(false); + for (const record of overlay.tasks.values()) + await this.#loadChain(parentOf(record), overlay); + if (await this.#region(() => this.#hasOwnedLive(current.id, overlayOf(tx)), this.#reloadScopes)) { + tx.setTask(withState(progressed(current), { status: "completing", outcome: next.outcome })); + return; + } + } + const same = jsonEqual(current.state, next); + tx.setTask(withState(same ? current : progressed(current), next)); + } + /** + * A wait names existing tasks, and `failFast` only tasks the waiter owns. It may not close a cycle in the wait-for + * graph, whose edges run from each task to what it waits on and to its live ordinary owned work (spec §5.5); waiting + * on itself or on an owner holding it are the shortest such cycles. An abort handler cannot wait. + * + * Invariant: the wait-for graph is acyclic. Only a wait adds an edge between existing tasks, as a new task has only + * incoming edges, so checking each wait maintains it. + */ + async #validateWait(tx, invocation, current, on, policy) { + if (invocation.mode === "abort") + throw new Error(`Abort handler of task ${current.id} cannot wait`); + await this.#loadScopes(false); + const ended = /* @__PURE__ */ new Map(); + for (; ; ) { + const checked = await this.#region(() => this.#checkWait(current, on, policy, overlayOf(tx), ended), this.#reloadScopes); + if (checked === void 0) + return; + if ("missing" in checked) { + for (const id of checked.missing) + ended.set(id, await this.#session.lineStorage.task(id, this.#context) ?? null); + continue; + } + throw checked.error; + } + } + /** + * `#validateWait()`'s decision region: why the wait is refused, `undefined` when it may be stored, or the tasks in + * `on` it needs from Storage first, when `ended` lacks them. + */ + #checkWait(current, on, policy, overlay, ended) { + const targets = []; + const missing = []; + for (const id of on) { + const member = overlay.tasks.get(id) ?? this.#liveRecord(id) ?? ended.get(id); + if (member === void 0) { + missing.push(id); + continue; + } + if (member === null) + return { error: new Error(`Task ${id} does not exist`) }; + if (policy === "failFast" && member.owner !== current.id) { + return { error: new Error(`Task ${current.id} can wait failFast only on tasks it owns; ${id} is not one`) }; + } + if (member.state.status !== "terminal" && member.owner !== current.id) + targets.push(id); + } + if (missing.length > 0) + return { missing }; + if (targets.length === 0) + return void 0; + const cycle = this.#waitCycle(current.id, targets, overlay); + if (cycle === void 0) + return void 0; + return { + error: new Error(`Task ${current.id} cannot wait on ${cycle[0]}: ${[current.id, ...cycle].join(" -> ")} is a wait cycle`) + }; + } + /** + * The path from a member of `on` back to `task` in the wait-for graph, if the wait would close a cycle (spec §5.1). + * An edge X -> Y means X cannot settle before Y: Y is in the `on` of waiting X that is not abort-marked, or Y is live + * ordinary owned work of X, which holds X as `completing`. Each task is visited once and each subtree walked once, + * so a check is bounded by the live work it reaches. Part of a decision region: it records every record it reads. + * Owned work is found by walks down, which need the chains of every live task loaded. + */ + #waitCycle(task, on, overlay) { + const from = /* @__PURE__ */ new Map(); + const walked = /* @__PURE__ */ new Set(); + const pending = []; + const reach = (id, via) => { + if (from.has(id)) + return; + from.set(id, via); + pending.push(id); + }; + const path = (end) => { + const steps = []; + for (let at = end; at !== void 0; at = from.get(at)) + steps.unshift(at); + return steps; + }; + for (const id of on) + reach(id, void 0); + for (let id = pending.pop(); id !== void 0; id = pending.pop()) { + if (id === task) + return path(id); + const record = overlay.tasks.get(id) ?? this.#liveRecord(id); + if (record === void 0 || record.state.status === "terminal") + continue; + if (record.state.status === "waiting" && !record.abortRequested) { + for (const member of record.state.on) + reach(member, id); + } + if (walked.has(id)) + continue; + walked.add(id); + const found = []; + const walk = this.#walkDown(id, { overlay, enter: (below) => !walked.has(below) }); + for (const { id: below, node, live } of walk) { + if (live === void 0 || node.background) + continue; + found.push(below); + reach(below, id); + } + for (const below of found) + walked.add(below); + } + return void 0; + } + /** End an invocation: its runtime operations reject from now on, its signal aborts, its watches stop, and its task is free. */ + #end(invocation) { + if (invocation.ended) + return; + invocation.ended = true; + if (this.#invocations.get(invocation.taskId) === invocation) { + this.#invocations.delete(invocation.taskId); + if (this.#live.has(invocation.taskId)) + this.#runnable.add(invocation.taskId); + } + for (const watch of invocation.watches) + void watch.stop(); + for (const disarm of invocation.timers) + disarm(); + invocation.timers.clear(); + invocation.controller.abort(endedError(invocation)); + } + /** No live non-background task in the scope; a task whose owner edges are not loaded yet counts as inside. */ + #idle(conversationId) { + const scope = conversationId === void 0 ? { roots: true } : { conversation: conversationId }; + if (conversationId === void 0) + this.#record(ROOTS_KEY); + for (const id of this.#unloaded) { + const record = this.#live.get(id); + if (record !== void 0 && !record.background && this.#inScope(parentOf(record), scope) !== false) + return false; + } + const starts = conversationId === void 0 ? this.#roots : [conversationId]; + for (const start of starts) { + for (const { node, live } of this.#walkDown(conversationNode(start))) { + if (live !== void 0 && !node.background) + return false; + } + } + return true; + } + // ─── Invocation runtime ────────────────────────────────────────────────── + #runtime(invocation, phase) { + const agent = () => { + if (invocation.ended) + return Promise.reject(endedError(invocation)); + if (phase.agent === void 0) { + phase.agent = this.#agent(invocation.conversationId, phase.snapshot(), invocation.context); + phase.agent.catch(() => { + }); + } + return phase.agent; + }; + const hooks = { + each: async (name, invoke) => { + for (const { extension, handlers } of agentHooks(await agent(), phase.task().definition.name)) { + const handler = handlers[name]; + if (typeof handler !== "function") + continue; + try { + await invoke(handler.bind(handlers)); + } catch (error) { + if (invocation.controller.signal.aborted) + throw error; + this.#report(error, { kind: "extension_error", extension, task: reportTask(invocation) }, invocation.context); + } + } + } + }; + const settings = this.#settings; + const models2 = this.#models; + return { + taskId: invocation.taskId, + conversationId: invocation.conversationId, + signal: invocation.controller.signal, + get models() { + return models2(); + }, + agent: (context) => invocation.ended ? Promise.reject(endedError(invocation)) : awaitWithContext(agent(), context), + get settings() { + return settings(); + }, + env: (context) => this.#read(invocation, () => this.#env(invocation.conversationId, context)), + hooks, + get registry() { + return phase.snapshot(); + }, + commit: async (change, context, options) => { + let checkpoint; + let interrupted2; + await this.#gated(invocation, async (tx, current) => { + const next = await change(tx, current); + if (next !== void 0) + await this.#commitState(tx, invocation, current, next); + if (next?.status === "running" && current.state.checkpoint.phase !== next.checkpoint.phase) { + checkpoint = next.checkpoint.phase; + } + if (next?.status === "terminal") { + const restarts = current.restarts ?? 0; + const reason = next.outcome.status === "failed" ? interruptionOf(next.outcome.error) : void 0; + if (reason !== void 0 && next.outcome.status === "failed") { + interrupted2 = { message: next.outcome.error.message, reason, restarts }; + } else { + const message = endedByCrashesStates.get(next); + if (message !== void 0) + interrupted2 = { message, reason: "restart_limit", restarts }; + } + } + }, context, options); + if (interrupted2 !== void 0) { + this.#report(new Error(interrupted2.message), { + kind: "task_interrupted", + ...this.#aboutTask(invocation), + reason: interrupted2.reason, + restarts: interrupted2.restarts + }, invocation.context); + } + if (checkpoint !== void 0) { + invocation.context.telemetry.addEvent("pi.durable.checkpoint", { + phase: checkpoint + }); + } + }, + memo: ((name, ...rest) => { + if (rest.length === 1) { + return this.#read(invocation, async () => memoOf(this.#live.get(invocation.taskId), name)); + } + const candidate = rest[0]; + return this.#gated(invocation, (tx, current) => { + const winner = memoOf(current, name); + if (winner !== void 0) + return winner; + tx.setTask({ ...current, memos: { ...current.memos, [name]: candidate } }); + return candidate; + }, rest[1]); + }), + sleepUntil: (until, checkpoint, context) => this.#gated(invocation, (tx, current) => this.#commitState(tx, invocation, current, { status: "sleeping", checkpoint, until }), context), + watchDoc: ((...args) => this.#watchDoc(invocation, args)), + snapshot: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshot")(...args))), + snapshotAsOf: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshotAsOf")(...args))), + getTask: ((id, context) => this.#read(invocation, () => this.#session.readOnLine((storage) => storage.task(id, context)))), + waitForTask: ((id, context) => this.#read(invocation, () => this.waitForTask(id, withAbortSignal(invocation.controller.signal, context)))), + ownedTasks: (context) => this.#read(invocation, () => this.#session.readOnLine(async () => { + if (invocation.ended) + throw endedError(invocation); + context.abortSignal?.throwIfAborted(); + const owned = await this.#region(() => { + this.#record(ownedKey(invocation.taskId)); + const records = []; + for (const node of this.#below.get(invocation.taskId) ?? []) { + const record = typeof node === "string" ? void 0 : this.#liveRecord(node); + if (record?.owner === invocation.taskId) + records.push(record); + } + return records; + }); + return owned.sort((a, b) => a.id - b.id).map((record) => copyJson(record)); + })), + abortOwned: (id, context) => this.#read(invocation, async () => { + const bound = withAbortSignal(invocation.controller.signal, context); + const record = await this.#session.readOnLine((storage) => storage.task(id, bound)); + if (record?.owner !== invocation.taskId) { + throw new Error(`Task ${id} is not owned by task ${invocation.taskId}`); + } + if (record.state.status === "terminal") + return; + await this.abort(id, bound, true); + await this.waitForTask(id, bound); + }), + outcomes: ((ids, context) => this.#read(invocation, () => this.#session.readOnLine(async (storage) => { + const outcomes = []; + for (const id of ids) { + const state = (await storage.task(id, context))?.state; + if (state?.status !== "terminal") + throw new Error(`Task ${id} is not terminal`); + outcomes.push(state.outcome); + } + return outcomes; + }))), + conversation: (id, context) => this.#read(invocation, () => this.#conversation(id, { + signal: invocation.controller.signal, + check: () => { + if (invocation.ended) + throw endedError(invocation); + } + }, context)), + entry: ((...args) => { + const [token, id, context] = args.length === 2 ? [void 0, args[0], args[1]] : [args[0], args[1], args[2]]; + return this.#read(invocation, async () => { + const kept = this.#contexts.get(invocation.conversationId); + const entry2 = (kept === void 0 ? void 0 : rangeEntry(kept.range, id)) ?? (await this.#session.readOnLine((storage) => storage.entry(invocation.conversationId, id, context)))?.entry; + return token === void 0 || entry2?.kind === token.kind ? entry2 : void 0; + }); + }), + entries: (query, limit, cursor, context) => this.#read(invocation, () => this.#session.readOnLine((storage) => storage.scanEntries(conversationEntries(invocation.conversationId, query), limit, cursor, context))), + context: (conversationId, context, options) => this.#read(invocation, async () => { + const read2 = await readContextFrom(this.#session, this.#storage, conversationId, context, options?.at, this.#contexts.get(conversationId)?.range); + const { view: view2 } = read2; + const range = read2.range?.current === true && this.#session.knownLatestEntryId(conversationId) !== read2.range.bounds.tail ? { ...read2.range, current: false } : read2.range; + const kept = this.#contexts.get(conversationId); + if (range !== void 0 && !this.#closing && !invocation.ended && (kept === void 0 || kept.range.bounds.tail <= range.bounds.tail)) { + if (!this.#idle(conversationId)) { + this.#contexts.set(conversationId, { range, idleSince: void 0 }); + this.#idleOrder.delete(conversationId); + } else if (this.#contextRetentionMs() > 0) { + const idleSince = kept?.idleSince ?? this.#now(); + const expiresAt = kept?.idleSince !== void 0 && kept.expiresAt !== void 0 ? kept.expiresAt : idleSince + this.#contextRetentionMs(); + this.#contexts.set(conversationId, { range, idleSince, expiresAt }); + if (kept?.idleSince === void 0) + this.#markIdle(conversationId, idleSince); + this.#scheduleExpiry(); + } + } + return view2; + }), + now: () => { + if (invocation.ended) + throw endedError(invocation); + return this.#now(); + }, + setTimer: (callback, delayMs) => { + if (invocation.ended) + return () => { + }; + const disarm = this.#timers.foreground(() => { + invocation.timers.delete(disarm); + if (!invocation.ended) + callback(); + }, delayMs); + invocation.timers.add(disarm); + return () => { + invocation.timers.delete(disarm); + disarm(); + }; + }, + report: (error, from) => { + if (invocation.ended) + throw endedError(invocation); + const task = reportTask(invocation); + this.#report(error, from === void 0 ? { kind: "task_error", ...this.#aboutTask(invocation) } : from.kind === "extension" ? { kind: "extension_error", task, ...optionalExtension(from.extension) } : { kind: "host_error", callback: from.callback, task }, invocation.context); + } + }; + } + /** + * On the line: wait until every commit admitted before is published, for a decision that reads what cannot be keyed, + * every live task (open, inspection) or every task a conversation abort reaches. + */ + async #published() { + const gate = this.#session.touch(ALL_KEYS); + if (gate !== void 0) + await gate; + } + /** Run a committed-state read unless the invocation has ended. */ + async #read(invocation, read2) { + if (invocation.ended) + throw endedError(invocation); + return read2(); + } + /** Commit after rereading the task on the line and gating the invocation. */ + #gated(invocation, change, context, options) { + if (invocation.ended) + return Promise.reject(endedError(invocation)); + return this.#session.commitWith(async (tx) => { + if (invocation.ended) + throw endedError(invocation); + if (this.#closing) + throw closedError(this.#session); + const current = await this.#region(() => { + const found = this.#liveRecord(invocation.taskId); + if (found === void 0) + throw new Error(`Task ${invocation.taskId} is terminal`); + if (found.state.status !== "running") + throw new Error(`Task ${invocation.taskId} is ${found.state.status}`); + if (invocation.mode === "run" && found.abortRequested) { + throw new Error(`Task ${invocation.taskId} has a durable abort mark`); + } + return found; + }); + if (invocation.ended) + throw endedError(invocation); + return change(tx, current); + }, context, { conversationId: invocation.conversationId, taskId: invocation.taskId }, options); + } + async #watchDoc(invocation, args) { + if (invocation.ended) + throw endedError(invocation); + const watch = await sessionMethod(this.#session, "watchDoc")(...args); + if (watch === void 0) + return void 0; + if (invocation.ended) { + void watch.stop(); + throw endedError(invocation); + } + invocation.watches.add(watch); + void watch.closed.then(() => invocation.watches.delete(watch)); + return watch; + } +}; +var REGISTRY = { kind: "host_error", callback: "registry" }; +var SETTINGS = { kind: "host_error", callback: "settings" }; +var ON_WAKE = { kind: "host_error", callback: "onWake" }; +var TELEMETRY2 = { kind: "host_error", callback: "telemetry" }; +function installerOf(snapshot, task) { + if (task === void 0) + return void 0; + return snapshot.installed().findLast((installed) => installed.tasks?.includes(task) === true)?.name; +} +function reportTask(task) { + return { id: task.taskId, kind: task.kind, conversationId: task.conversationId }; +} +function recordTask(record) { + return { taskId: record.id, kind: record.kind, conversationId: record.conversationId }; +} +var endedByCrashesStates = /* @__PURE__ */ new WeakMap(); +function endedByCrashes(state, message) { + endedByCrashesStates.set(state, message); + return state; +} +function interruptionOf(error) { + const detail = error.detail; + if (typeof detail !== "object" || detail === null || !("reason" in detail)) + return void 0; + return detail.reason === "restart_limit" || detail.reason === "interrupted" ? detail.reason : void 0; +} +function withAbortMark(record, reason) { + const { abortReason: _reason, restarts: _restarts, ...rest } = record; + return { ...rest, abortRequested: true, ...reason === void 0 ? {} : { abortReason: reason } }; +} +function cancellationIntent(record) { + return record.state.status !== "terminal" && (record.abortRequested || failedOutcome(record)); +} +function intentChanges(previous, next) { + return (previous?.abortRequested ?? false) !== next.abortRequested || previous?.abortReason !== next.abortReason || (previous !== void 0 && failedOutcome(previous)) !== failedOutcome(next) || (previous !== void 0 && cancellationIntent(previous)) !== cancellationIntent(next); +} +function failedOutcome(record) { + const state = record.state; + return (state.status === "completing" || state.status === "terminal") && state.outcome.status !== "completed"; +} +function parentOf(node) { + return node.owner !== void 0 ? { task: node.owner } : { conversation: node.conversationId }; +} +function nodeOf(record) { + return { + conversationId: record.conversationId, + ...record.owner === void 0 ? {} : { owner: record.owner }, + background: record.background + }; +} +function overlayOf(tx) { + const tasks = new Map(tx.stagedTasks().map((record) => [record.id, record])); + const edges = new Map(tx.stagedConversations().map((record) => [record.id, record.owner?.taskId ?? null])); + return { tasks, edges }; +} +function addTo(map, key, value2) { + const values = map.get(key); + if (values === void 0) + map.set(key, /* @__PURE__ */ new Set([value2])); + else + values.add(value2); +} +function deleteFrom(map, key, value2) { + const values = map.get(key); + if (values === void 0) + return; + values.delete(value2); + if (values.size === 0) + map.delete(key); +} +function memoOf(record, name) { + const memos = record?.memos; + return memos !== void 0 && Object.hasOwn(memos, name) ? memos[name] : void 0; +} +function sessionMethod(session, name) { + return session[name].bind(session); +} +function missingMigration(record, definition) { + return new Error(`Task ${record.kind} version ${definition.version} has no migration from ${record.version}`); +} +function erased(task) { + return task.definition; +} +function endedError(invocation) { + return new Error(`Task ${invocation.taskId} invocation has ended`); +} +function progressed(record) { + if (record.restarts === void 0) + return record; + const { restarts: _restarts, ...rest } = record; + return rest; +} +function withState(record, state) { + if (state.status !== "terminal" && state.status !== "completing") + return { ...record, state }; + const { memos: _memos, restarts: _restarts, ...rest } = record; + return { ...rest, state }; +} +function outdatedReason(task, record) { + if (task === void 0) + return "missing_task"; + return task.definition.version < record.version ? "task_too_old" : "migration_failed"; +} +function canReserve(task, record) { + const definition = task.definition; + return definition.version === record.version || definition.version > record.version && definition.migrate !== void 0; +} +function validateSleep(invocation, task, until) { + if (invocation.mode === "abort") + throw new Error(`Abort handler of task ${task} cannot sleep`); + if (typeof until !== "number" || !Number.isFinite(until) || until < 0) { + throw new Error(`Task ${task} cannot sleep until ${until}: not a time in milliseconds`); + } +} +function jsonEqual(left, right) { + if (left === right) + return true; + if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) + return false; + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) + return false; + return left.every((value2, index) => jsonEqual(value2, right[index])); + } + const keys = Object.keys(left); + if (keys.length !== Object.keys(right).length) + return false; + return keys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key])); +} + +// node_modules/@earendil-works/chord/dist/output.js +var NEWLINE = 10; +var INVALID_OUTPUT = /[\x00-\x08\x0b-\x1f\ufff9-\ufffb]/g; +var ESCAPE_SEQUENCE = /\x1b[\]PX^_][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x20-\x2f]*[\x30-\x7e]/g; +var encoder = new TextEncoder(); +var decoder = new TextDecoder("utf-8", { ignoreBOM: true }); +function sanitizeOutput(text) { + return text.replace(ESCAPE_SEQUENCE, "").replace(INVALID_OUTPUT, ""); +} +function boundOutput(text, limits) { + const bytes = encoder.encode(text); + const [from, to] = limits.retain === "head" ? headRange(bytes, limits) : tailRange(bytes, limits); + const kept = bytes.subarray(from, to); + return { + text: kept.length === bytes.length ? text : decoder.decode(kept), + bytes: kept.length, + droppedBytes: bytes.length - kept.length, + droppedLines: lineCount(bytes) - lineCount(kept) + }; +} +function headRange(bytes, limits) { + if (limits.maxLines === 0 || limits.maxBytes === 0) + return [0, 0]; + let end = bytes.length; + let lines2 = 0; + for (let index = bytes.indexOf(NEWLINE); index !== -1; index = bytes.indexOf(NEWLINE, index + 1)) { + if (++lines2 === limits.maxLines) { + end = index + 1; + break; + } + } + if (end > limits.maxBytes) { + const newline = bytes.lastIndexOf(NEWLINE, limits.maxBytes - 1); + end = newline === -1 ? characterEnd(bytes, limits.maxBytes) : newline + 1; + } + return [0, end]; +} +function tailRange(bytes, limits) { + if (limits.maxLines === 0 || limits.maxBytes === 0) + return [bytes.length, bytes.length]; + const last = bytes[bytes.length - 1] === NEWLINE ? bytes.length - 2 : bytes.length - 1; + let start = 0; + let lines2 = 1; + for (let index = last < 0 ? -1 : bytes.lastIndexOf(NEWLINE, last); index !== -1; ) { + if (lines2 === limits.maxLines) { + start = index + 1; + break; + } + lines2++; + index = index === 0 ? -1 : bytes.lastIndexOf(NEWLINE, index - 1); + } + if (bytes.length - start > limits.maxBytes) { + const from = bytes.length - limits.maxBytes; + const newline = bytes.indexOf(NEWLINE, from - 1); + start = newline !== -1 && newline + 1 < bytes.length ? newline + 1 : characterStart(bytes, from); + } + return [start, bytes.length]; +} +function characterEnd(bytes, index) { + let end = index; + while (end > 0 && ((bytes[end] ?? 0) & 192) === 128) + end--; + return end; +} +function characterStart(bytes, index) { + let start = index; + while (start < bytes.length && ((bytes[start] ?? 0) & 192) === 128) + start++; + return start; +} +function lineCount(bytes) { + if (bytes.length === 0) + return 0; + let newlines = 0; + for (let index = bytes.indexOf(NEWLINE); index !== -1; index = bytes.indexOf(NEWLINE, index + 1)) + newlines++; + return newlines + (bytes[bytes.length - 1] === NEWLINE ? 0 : 1); +} +var OutputBuffer = class { + #limits; + /** + * Decoder of byte chunks. A string chunk or a skip ends an incomplete character of earlier bytes (it becomes U+FFFD); + * the next byte chunk then starts a new character. As in `StreamDecoder`, only a byte-order mark at the very start of + * the output is dropped, never a U+FEFF later in it. + */ + #decoder = new TextDecoder("utf-8", { ignoreBOM: true }); + #started = false; + /** Stored chunks: for head the start of the stream, for tail a suffix that still contains the next window. */ + #chunks = []; + #storedBytes = 0; + #storedNewlines = 0; + #full = false; + #totalBytes = 0; + #totalNewlines = 0; + #endsWithNewline = true; + constructor(limits) { + this.#limits = limits; + } + /** Bytes currently held; bounded by the limits plus one chunk. */ + get storedBytes() { + return this.#storedBytes; + } + /** + * Accept a chunk; returns whether anything was accepted. `skipped` is output omitted right before the chunk, which + * must be more than the tail window by at least one byte or line; only tail retention accepts it. + */ + push(chunk, skipped) { + const pending = typeof chunk === "string" || skipped !== void 0 ? this.#decoder.decode() : ""; + let text = typeof chunk === "string" ? chunk : this.#decoder.decode(chunk, { stream: true }); + const first = !this.#started && pending === "" && skipped === void 0; + if (pending !== "" || text !== "" || skipped !== void 0) + this.#started = true; + if (first && typeof chunk !== "string" && text.startsWith("\uFEFF")) + text = text.slice(1); + if (skipped === void 0) + return this.#accept(pending + text); + if (this.#limits.retain !== "tail") + throw new Error("Skipped output requires tail retention"); + this.#accept(pending); + this.#skip(skipped); + this.#accept(text); + return true; + } + /** Count omitted output; nothing stored before it can be in the window once the text after it arrives. */ + #skip(skipped) { + if (skipped.bytes === 0) + return; + this.#totalBytes += skipped.bytes; + this.#totalNewlines += skipped.newlines; + this.#endsWithNewline = skipped.endsWithNewline; + this.#chunks = []; + this.#storedBytes = 0; + this.#storedNewlines = 0; + } + /** Flush an incomplete trailing character as a replacement character; call when the stream ends. */ + end() { + this.#accept(this.#decoder.decode()); + } + #accept(text) { + if (text.length === 0) + return false; + const bytes = utf8ByteLength(text); + const newlines = countNewlines(text); + this.#totalBytes += bytes; + this.#totalNewlines += newlines; + this.#endsWithNewline = text.endsWith("\n"); + if (this.#full) + return true; + this.#chunks.push({ text, bytes, newlines }); + this.#storedBytes += bytes; + this.#storedNewlines += newlines; + if (this.#limits.retain === "head") { + this.#full = this.#storedBytes > this.#limits.maxBytes || this.#storedNewlines >= this.#limits.maxLines; + return true; + } + while (this.#chunks.length > 1) { + const first = this.#chunks[0]; + const bytesAfter = this.#storedBytes - first.bytes; + const newlinesAfter = this.#storedNewlines - first.newlines; + if (bytesAfter <= this.#limits.maxBytes + 1 && newlinesAfter <= this.#limits.maxLines + 1) + break; + this.#chunks.shift(); + this.#storedBytes = bytesAfter; + this.#storedNewlines = newlinesAfter; + } + return true; + } + /** Retained, sanitized output and what the limits dropped from the whole stream. */ + snapshot() { + const stored = this.#chunks.length === 1 ? this.#chunks[0].text : this.#chunks.map((chunk) => chunk.text).join(""); + const kept = boundOutput(stored, this.#limits); + const storedLines = lines(this.#storedNewlines, stored === "" || stored.endsWith("\n")); + const keptLines = storedLines - kept.droppedLines; + if (this.#limits.retain === "tail" || this.#chunks.length > 1) { + const text = this.#limits.retain === "tail" ? tailMargin(stored, this.#limits) : stored; + const bytes = this.#limits.retain === "tail" ? utf8ByteLength(text) : this.#storedBytes; + this.#chunks = text === "" ? [] : [{ text, bytes, newlines: countNewlines(text) }]; + this.#storedBytes = bytes; + this.#storedNewlines = this.#chunks[0]?.newlines ?? 0; + } + return { + text: sanitizeOutput(kept.text), + droppedBytes: this.#totalBytes - kept.bytes, + droppedLines: lines(this.#totalNewlines, this.#endsWithNewline) - keptLines + }; + } +}; +function tailMargin(text, limits) { + const bytes = encoder.encode(text); + const byteStart = bytes.length > limits.maxBytes ? characterEnd(bytes, bytes.length - limits.maxBytes - 1) : 0; + let lineStart = 0; + let newlines = 0; + for (let index = bytes.lastIndexOf(NEWLINE); index !== -1; index = bytes.lastIndexOf(NEWLINE, index - 1)) { + if (++newlines > limits.maxLines) { + lineStart = index; + break; + } + if (index === 0) + break; + } + const start = Math.max(byteStart, lineStart); + return start === 0 ? text : decoder.decode(bytes.subarray(start)); +} +function lines(newlines, terminated) { + return newlines + (terminated ? 0 : 1); +} +var runtimeBuffer = globalThis.Buffer; +var nonAsciiPattern = /[^\x00-\x7f]/; +function utf8ByteLength(content) { + if (runtimeBuffer) + return runtimeBuffer.byteLength(content, "utf8"); + const firstNonAscii = content.search(nonAsciiPattern); + if (firstNonAscii === -1) + return content.length; + let bytes = firstNonAscii; + for (let i = firstNonAscii; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code <= 127) + bytes += 1; + else if (code <= 2047) + bytes += 2; + else if (code >= 55296 && code <= 56319 && i + 1 < content.length) { + const next = content.charCodeAt(i + 1); + if (next >= 56320 && next <= 57343) { + bytes += 4; + i++; + } else + bytes += 3; + } else + bytes += 3; + } + return bytes; +} +function countNewlines(text) { + let count = 0; + for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) + count++; + return count; +} +var PROGRESS_BYTES_PER_SECOND = 100 * 1024; +var SYSTEM_CLOCK = { + now: () => Date.now(), + setTimer: (callback, delayMs) => { + const timer = setTimeout(callback, delayMs); + return () => clearTimeout(timer); + } +}; +var Progress = class { + #write; + #onError; + #minIntervalMs; + #clock; + #waiters = []; + #timer; + #inFlight; + #nextAt = 0; + #dirty = false; + #stopped = false; + constructor(write, onError, minIntervalMs, clock = SYSTEM_CLOCK) { + this.#write = write; + this.#onError = onError; + this.#minIntervalMs = minIntervalMs; + this.#clock = clock; + } + /** Schedule a commit. */ + mark() { + this.#dirty = true; + this.#schedule(); + } + /** Schedule a commit; the promise settles with the commit that includes this change. */ + markAndWait() { + const waiter = Promise.withResolvers(); + this.#waiters.push(waiter); + this.mark(); + return waiter.promise; + } + /** Stop committing and wait for the commit in flight; returns the waiters the final commit must settle. */ + async stop() { + this.#stopped = true; + this.#timer?.(); + this.#timer = void 0; + await this.#inFlight; + return this.#waiters.splice(0); + } + #schedule() { + if (this.#stopped || this.#timer !== void 0 || this.#inFlight !== void 0) + return; + const wait = this.#nextAt - this.#clock.now(); + if (wait <= 0) + this.#flush(); + else + this.#timer = this.#clock.setTimer(() => { + this.#timer = void 0; + this.#schedule(); + }, wait); + } + #flush() { + if (this.#stopped || !this.#dirty) + return; + this.#dirty = false; + const waiters = this.#waiters.splice(0); + const started = this.#clock.now(); + this.#inFlight = this.#write().then((bytes) => { + this.#nextAt = started + Math.max(this.#minIntervalMs, bytes * 1e3 / PROGRESS_BYTES_PER_SECOND); + for (const waiter of waiters) + waiter.resolve(); + }, (error) => { + this.#nextAt = started + this.#minIntervalMs; + for (const waiter of waiters) + waiter.reject(error); + this.#onError(error); + }).finally(() => { + this.#inFlight = void 0; + if (this.#dirty) + this.#schedule(); + }); + } +}; + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/date.js +var DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; +var DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/; +function IsLeapYear(year) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} +function IsDate(value2) { + const matches = DATE.exec(value2); + if (!matches) + return false; + const year = +matches[1]; + const month = +matches[2]; + const day = +matches[3]; + return month >= 1 && month <= 12 && day >= 1 && day <= (month === 2 && IsLeapYear(year) ? 29 : DAYS[month]); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/time.js +var TIME = /^(\d\d):(\d\d):(\d\d)(?:\.\d+)?(?:([Zz])|([+-])(\d\d):(\d\d))?$/; +function IsTime(value2, strictTimeZone = true) { + const matches = TIME.exec(value2); + if (!matches) + return false; + if (strictTimeZone && !matches[4] && !matches[5]) + return false; + const hr = +matches[1]; + const min = +matches[2]; + const sec = +matches[3]; + if (hr > 23 || min > 59 || sec > 60) + return false; + if (matches[5]) { + const tzH2 = +matches[6]; + const tzM2 = +matches[7]; + if (tzH2 > 23 || tzM2 > 59) + return false; + } + if (sec < 60) + return true; + const tzSign = matches[5] === "-" ? -1 : 1; + const tzH = +(matches[6] || 0); + const tzM = +(matches[7] || 0); + const totalUtcMin = hr * 60 + min - tzSign * (tzH * 60 + tzM); + return (totalUtcMin % 1440 + 1440) % 1440 === 1439; +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/date_time.js +function IsDateTime(value2) { + const dateTime = value2.split(/T/i); + return dateTime.length === 2 && IsDate(dateTime[0]) && IsTime(dateTime[1]); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/duration.js +var Duration = /^P((\d+Y(\d+M(\d+D)?)?|\d+M(\d+D)?|\d+D)(T(\d+H(\d+M(\d+S)?)?|\d+M(\d+S)?|\d+S))?|T(\d+H(\d+M(\d+S)?)?|\d+M(\d+S)?|\d+S)|\d+W)$/; +function IsDuration(value2) { + return Duration.test(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/email.js +var Email = /^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[^"\\]|\\[\x20-\x7e])*")@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*|\[(?:IPv6:[a-f0-9:]+|(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})\])$/i; +function IsEmail(value2) { + return Email.test(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/idna/pattern/pattern.js +var RE_RULE_HYPHEN_PLACEMENT = /^(?!-).*(? regexp.source).join("|"), "u"); +var RE_PERMITTED_CATEGORY = new RegExp([ + RE_LETTER, + RE_EUROPEAN_SEPARATOR, + RE_COMMON_SEPARATOR, + RE_NUMBER_DECIMAL, + RE_MARK_NONSPACING, + RE_MARK_SPACING_COMBINING, + RE_CONTEXTO_EXCEPTIONS, + RE_PVALID_EXCEPTIONS +].map((regexp) => regexp.source).join("|"), "u"); + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/idna/label/ascii.js +function IsAsciiLabel(value2) { + return RE_RULE_HYPHEN_PLACEMENT.test(value2) && RE_RULE_NOT_RESERVED_ACE.test(value2) && RE_ASCII_LDH.test(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/idna/format/puny.js +var PUNYCODE_BASE = 36; +var PUNYCODE_TMIN = 1; +var PUNYCODE_TMAX = 26; +var PUNYCODE_SKEW = 38; +var PUNYCODE_DAMP = 700; +var PUNYCODE_INITIAL_BIAS = 72; +var PUNYCODE_INITIAL_N = 128; +function ThrowDontCare() { + throw null; +} +function IsAcePrefixed(value2) { + return value2.toLowerCase().startsWith("xn--"); +} +function Adapt(delta, numPoints, firstTime) { + delta = firstTime ? Math.floor(delta / PUNYCODE_DAMP) : delta >> 1; + delta += Math.floor(delta / numPoints); + let k = 0; + while (delta > (PUNYCODE_BASE - PUNYCODE_TMIN) * PUNYCODE_TMAX >> 1) { + delta = Math.floor(delta / (PUNYCODE_BASE - PUNYCODE_TMIN)); + k += PUNYCODE_BASE; + } + return k + Math.floor((PUNYCODE_BASE - PUNYCODE_TMIN + 1) * delta / (delta + PUNYCODE_SKEW)); +} +function Decode(value2) { + const output = []; + let n = PUNYCODE_INITIAL_N; + let i = 0; + let bias = PUNYCODE_INITIAL_BIAS; + const delimIdx = value2.lastIndexOf("-"); + if (delimIdx > 0) { + for (let j = 0; j < delimIdx; j++) { + const cp = value2.charCodeAt(j); + if (cp >= 128) + ThrowDontCare(); + output.push(cp); + } + } + let inIdx = delimIdx < 0 ? 0 : delimIdx + 1; + while (inIdx < value2.length) { + const oldi = i; + let w = 1; + let k = PUNYCODE_BASE; + while (true) { + if (inIdx >= value2.length) + ThrowDontCare(); + const ch = value2.charCodeAt(inIdx++); + let digit; + if (ch >= 97 && ch <= 122) + digit = ch - 97; + else if (ch >= 48 && ch <= 57) + digit = ch - 48 + 26; + else + ThrowDontCare(); + i += digit * w; + const t = k <= bias ? PUNYCODE_TMIN : k >= bias + PUNYCODE_TMAX ? PUNYCODE_TMAX : k - bias; + if (digit < t) + break; + w *= PUNYCODE_BASE - t; + k += PUNYCODE_BASE; + } + const outLen = output.length + 1; + bias = Adapt(i - oldi, outLen, oldi === 0); + n += Math.floor(i / outLen); + i %= outLen; + output.splice(i, 0, n); + i++; + } + return String.fromCodePoint(...output); +} +function DigitToChar(digit) { + return digit < 26 ? String.fromCharCode(digit + 97) : String.fromCharCode(digit - 26 + 48); +} +var RE_REPLACE_NON_ASCII = new RegExp(RE_NON_ASCII.source, "gu"); +function Encode(input) { + const basic = input.replace(RE_REPLACE_NON_ASCII, ""); + const basicLength = basic.length; + const result = basicLength > 0 ? [basic, "-"] : []; + const codePoints2 = Array.from(input, (char) => char.codePointAt(0)); + let n = PUNYCODE_INITIAL_N; + let delta = 0; + let bias = PUNYCODE_INITIAL_BIAS; + let handledCPCount = basicLength; + while (handledCPCount < codePoints2.length) { + let m = Infinity; + for (const cp of codePoints2) { + if (cp >= n && cp < m) + m = cp; + } + delta += (m - n) * (handledCPCount + 1); + n = m; + for (const cp of codePoints2) { + if (cp < n) + delta++; + if (cp === n) { + let q = delta; + for (let k = PUNYCODE_BASE; ; k += PUNYCODE_BASE) { + const t = k <= bias ? PUNYCODE_TMIN : k >= bias + PUNYCODE_TMAX ? PUNYCODE_TMAX : k - bias; + if (q < t) + break; + const digit = t + (q - t) % (PUNYCODE_BASE - t); + result.push(DigitToChar(digit)); + q = Math.floor((q - t) / (PUNYCODE_BASE - t)); + } + result.push(DigitToChar(q)); + bias = Adapt(delta, handledCPCount + 1, handledCPCount === basicLength); + delta = 0; + handledCPCount++; + } + } + delta++; + n++; + } + return result.join(""); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/idna/format/bidi.js +var RE_RTL_ALLOWED = /^(?:R|AL|AN|EN|ES|CS|ET|ON|BN|NSM)$/; +var RE_LTR_ALLOWED = /^(?:L|EN|ES|CS|ET|ON|BN|NSM)$/; +var RE_RTL_CLASSES = /^(?:R|AL|AN)$/; +function HasBidiChars(value2) { + if (IsAcePrefixed(value2)) { + try { + return HasRightToLeftCharacters(Decode(value2.slice(4).toLowerCase())); + } catch { + return false; + } + } + return HasRightToLeftCharacters(value2); +} +function GetBidiClass(codePoint) { + const char = String.fromCodePoint(codePoint); + return RE_EUROPEAN_NUMBER.test(char) ? "EN" : RE_ARABIC_INDIC_DIGIT.test(char) ? "AN" : ( + // Pattern.RE_EUROPEAN_SEPARATOR.test(char) ? 'ES' : // (no-spec-coverage) + // Pattern.RE_COMMON_SEPARATOR.test(char) ? 'CS' : // (no-spec-coverage) + RE_MARK_NONSPACING.test(char) ? "NSM" : RE_SCRIPT_HEBREW.test(char) ? "R" : RE_SCRIPT_ARABIC_LETTER.test(char) ? "AL" : RE_LETTER.test(char) ? "L" : "ON" + ); +} +function HasRightToLeftCharacters(value2) { + for (const ch of value2) + if (RE_RTL_CLASSES.test(GetBidiClass(ch.codePointAt(0)))) + return true; + return false; +} +function SatisfiesBidiRule(value2) { + let isRtl = false; + let allowed = RE_LTR_ALLOWED; + let sawEN = false; + let sawAN = false; + let isFirst = true; + for (const ch of value2) { + const bidiClass = GetBidiClass(ch.codePointAt(0)); + if (isFirst) { + if (bidiClass !== "L" && bidiClass !== "R" && bidiClass !== "AL") + return false; + isRtl = bidiClass === "R" || bidiClass === "AL"; + allowed = isRtl ? RE_RTL_ALLOWED : RE_LTR_ALLOWED; + isFirst = false; + } + if (!allowed.test(bidiClass)) + return false; + if (bidiClass === "EN") + sawEN = true; + else if (bidiClass === "AN") + sawAN = true; + } + if (isRtl && sawEN && sawAN) + return false; + return true; +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/idna/label/unicode.js +function ExceedsMaxALabelLength(value2) { + return RE_NON_ASCII.test(value2) && Encode(value2).length + 4 > 63; +} +function HasInvalidHyphens(chars) { + if (chars[0] === "-" || chars[chars.length - 1] === "-") + return true; + return chars.slice(2).join("").startsWith("--"); +} +function IsUnicodeLabel(value2) { + if (ExceedsMaxALabelLength(value2)) + return false; + if (HasRightToLeftCharacters(value2) && !SatisfiesBidiRule(value2)) + return false; + const chars = [...value2]; + const codePoints2 = chars.map((c) => c.codePointAt(0)); + const length = codePoints2.length; + if (HasInvalidHyphens(chars)) + return false; + if (RE_COMBINING_MARK.test(chars[0])) + return false; + let hasJapanese = false; + for (let i = 0; i < length; i++) { + const codePoint = codePoints2[i]; + const char = chars[i]; + if (RE_RFC5892_DISALLOWED.test(char)) + return false; + if (!RE_PERMITTED_CATEGORY.test(char)) + return false; + if (RE_SCRIPT_JAPANESE.test(char)) + hasJapanese = true; + const prev = codePoints2[i - 1], next = codePoints2[i + 1]; + switch (codePoint) { + case 183: + if (prev !== 108 || next !== 108) + return false; + break; + // MIDDLE DOT (Catalan) + case 885: + if (!next || !RE_SCRIPT_GREEK.test(chars[i + 1])) + return false; + break; + // Greek KERAIA + case 1523: + case 1524: + if (!prev || !RE_SCRIPT_HEBREW.test(chars[i - 1])) + return false; + break; + // Hebrew GERESH + case 8204: + if (!prev || prev < 128 && !RE_VIRAMA.test(chars[i - 1])) + return false; + break; + case 8205: + if (!prev || !RE_VIRAMA.test(chars[i - 1])) + return false; + break; + case 12539: + break; + } + } + if (value2.includes("\u30FB") && !hasJapanese) + return false; + return true; +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/idna/label/puny.js +function IsPunyLabel(value2) { + if (!IsAcePrefixed(value2)) + return false; + try { + const body = value2.slice(4).toLowerCase(); + if (body.lastIndexOf("-") === 0) + return false; + const decoded = Decode(body); + if (!RE_NON_ASCII.test(decoded)) + return false; + return IsUnicodeLabel(decoded); + } catch { + return false; + } +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/idna/hostname.js +function IsValidLabelLength(value2) { + return value2.length > 0 && value2.length <= 63; +} +function IsLabel(value2) { + return IsValidLabelLength(value2) && (IsPunyLabel(value2) || IsAsciiLabel(value2)); +} +function IsHostname(value2) { + if (value2.length === 0 || value2.length > 253) + return false; + if (value2.charCodeAt(value2.length - 1) === 46) + return false; + return value2.split(".").every((label) => IsLabel(label)); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/idna/idn-hostname.js +function IsValidLabelLength2(value2) { + return value2.length > 0 && value2.length <= 63; +} +function IsLabel2(value2) { + return IsValidLabelLength2(value2) && (IsPunyLabel(value2) || IsUnicodeLabel(value2)); +} +function NormalizeHostname(value2) { + return value2.replace(/[\uff01-\uff5e]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 65248)).normalize("NFC").replace(/[\u00ad\u034f\u180b-\u180d\u200b\ufe00-\ufe0f\u{e0100}-\u{e01ef}]/gu, "").replace(/[\u002E\u3002\uFF0E\uFF61]/g, "."); +} +function IsIdnHostname(value2) { + if (value2.length === 0 || value2.includes(" ")) + return false; + const normalized = NormalizeHostname(value2); + if (normalized.length > 253) + return false; + const labels = normalized.split("."); + const hasBidiChars = labels.some((label) => HasBidiChars(label)); + return labels.every((label) => IsLabel2(label) && (!hasBidiChars || SatisfiesBidiRule(label))); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/hostname.js +function IsHostname2(value2) { + return IsHostname(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/idn_email.js +var IdnEmail = /^(?:[A-Za-z0-9!#$%&'*+/=?^_`{|}~\u{0080}-\u{10FFFF}-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~\u{0080}-\u{10FFFF}-]+)*|"(?:[^"\\]|\\.)*")@[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,62})(?^`{|}\\]/; +var InvalidPercentEncoding = /%(?![0-9a-fA-F]{2})/; +function NarrowIpvFuture(value2) { + return value2.length < IpvFutureMatchMaxLength ? value2.replace(IpvFutureMatch, "[::1]") : value2; +} +function IsIri(value2) { + if (InvalidIriChars.test(value2)) + return false; + if (InvalidPercentEncoding.test(value2)) + return false; + return URL.canParse(NarrowIpvFuture(value2)); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/iri_reference.js +var InvalidIriChars2 = /[\x00-\x20\x7F\\]|%(?![0-9a-fA-F]{2})/; +var MalformedScheme = /^[a-zA-Z][a-zA-Z0-9+\-.]*\/\//; +function IsIriReference(value2) { + return !InvalidIriChars2.test(value2) && !MalformedScheme.test(value2) && URL.canParse(value2, "http://example.com"); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/json_pointer.js +var JsonPointer = /^(?:\/(?:[^~/]|~0|~1)*)*$/; +function IsJsonPointer(value2) { + return JsonPointer.test(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/json_pointer_uri_fragment.js +var JsonPointerUriFragment = /^#(?:\/(?:[a-z0-9_\-.!$&'()*+,;:=@]|%[0-9a-f]{2}|~0|~1)*)*$/i; +function IsJsonPointerUriFragment(value2) { + return JsonPointerUriFragment.test(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/regex.js +function IsRegex(value2) { + try { + new RegExp(value2, "u"); + return true; + } catch { + return false; + } +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/relative_json_pointer.js +var RelativeJsonPointer = /^(?:0|[1-9][0-9]*)(?:#|(?:\/(?:[^~/]|~0|~1)*)*)$/; +function IsRelativeJsonPointer(value2) { + return RelativeJsonPointer.test(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/uri.js +var Uri = /^[a-z][a-z0-9+\-.]*:(?:\/\/(?:(?:[-a-z0-9._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:[\da-f]{1,4}:){6}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|::(?:[\da-f]{1,4}:){5}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:[\da-f]{1,4})?::(?:[\da-f]{1,4}:){4}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,1}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){3}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,2}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){2}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,3}[\da-f]{1,4})?::[\da-f]{1,4}:(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,4}[\da-f]{1,4})?::(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,5}[\da-f]{1,4})?::[\da-f]{1,4}|(?:(?:[\da-f]{1,4}:){0,6}[\da-f]{1,4})?::)|v[0-9a-f]+\.[-a-z0-9._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)|(?:[-a-z0-9._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?(?:\?(?:[-a-z0-9._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[-a-z0-9._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i; +function IsUri(value2) { + return Uri.test(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/uri_reference.js +var UriReference = /^(?:[a-z][a-z0-9+\-.]*:(?:\/\/(?:(?:[-a-z0-9._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:[\da-f]{1,4}:){6}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|::(?:[\da-f]{1,4}:){5}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:[\da-f]{1,4})?::(?:[\da-f]{1,4}:){4}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,1}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){3}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,2}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){2}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,3}[\da-f]{1,4})?::[\da-f]{1,4}:(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,4}[\da-f]{1,4})?::(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,5}[\da-f]{1,4})?::[\da-f]{1,4}|(?:(?:[\da-f]{1,4}:){0,6}[\da-f]{1,4})?::)|v[0-9a-f]+\.[-a-z0-9._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)|(?:[-a-z0-9._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:\/\/(?:(?:[-a-z0-9._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:[\da-f]{1,4}:){6}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|::(?:[\da-f]{1,4}:){5}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:[\da-f]{1,4})?::(?:[\da-f]{1,4}:){4}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,1}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){3}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,2}[\da-f]{1,4})?::(?:[\da-f]{1,4}:){2}(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,3}[\da-f]{1,4})?::[\da-f]{1,4}:(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,4}[\da-f]{1,4})?::(?:[\da-f]{1,4}:[\da-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d))|(?:(?:[\da-f]{1,4}:){0,5}[\da-f]{1,4})?::[\da-f]{1,4}|(?:(?:[\da-f]{1,4}:){0,6}[\da-f]{1,4})?::)|v[0-9a-f]+\.[-a-z0-9._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)|(?:[-a-z0-9._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[-a-z0-9._~!$&'()*+,;=@]|%[0-9a-f]{2})+(?:\/(?:[-a-z0-9._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?)(?:\?(?:[-a-z0-9._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[-a-z0-9._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i; +function IsUriReference(value2) { + return UriReference.test(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/uri_template.js +var UriTemplate = /^(?:(?:[^\x00-\x20"<>%\\^`{|}\x7f]|%[0-9a-f]{2})|\{[+#./;?&=,!@|]?(?:[a-z0-9_]|%[0-9a-f]{2})+(?:\.(?:[a-z0-9_]|%[0-9a-f]{2})+)*(?::[1-9]\d{0,3}|\*)?(?:,(?:[a-z0-9_]|%[0-9a-f]{2})+(?:\.(?:[a-z0-9_]|%[0-9a-f]{2})+)*(?::[1-9]\d{0,3}|\*)?)*\})*$/i; +function IsUriTemplate(value2) { + return UriTemplate.test(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/url.js +function IsUrl(value2) { + return URL.canParse(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/typebox/uuid.js +var Uuid = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i; +function IsUuid(value2) { + return Uuid.test(value2); +} + +// node_modules/@earendil-works/pi-schema/dist/formats/index.js +var formats = Object.freeze({ + date: IsDate, + "date-time": IsDateTime, + duration: IsDuration, + email: IsEmail, + hostname: IsHostname2, + "idn-email": IsIdnEmail, + "idn-hostname": IsIdnHostname2, + ipv4: IsIPv4, + ipv6: IsIPv6, + iri: IsIri, + "iri-reference": IsIriReference, + "json-pointer": IsJsonPointer, + "json-pointer-uri-fragment": IsJsonPointerUriFragment, + regex: IsRegex, + "relative-json-pointer": IsRelativeJsonPointer, + time: IsTime, + uri: IsUri, + "uri-reference": IsUriReference, + "uri-template": IsUriTemplate, + url: IsUrl, + uuid: IsUuid +}); + +// node_modules/@earendil-works/pi-ai/dist/utils/validation.js +var validatorCache = /* @__PURE__ */ new WeakMap(); +function getValidator(schema) { + const cached = validatorCache.get(schema); + if (cached) { + return cached; + } + const validator = compile(schema, { formats, coerce: true, unresolvedRefs: "reject" }); + validatorCache.set(schema, validator); + return validator; +} +function formatValidationPath(pointer2) { + if (pointer2 === "") + return "root"; + return pointer2.slice(1).split("/").map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")).join("."); +} +function validateToolArguments(tool, toolCall) { + if (toolCall.argumentsError) { + throw new Error(`Invalid arguments for tool "${toolCall.name}": ${toolCall.argumentsError}`); + } + const result = getValidator(tool.parameters).parse(structuredClone(toolCall.arguments)); + if (result.ok) { + return result.value; + } + const errors = result.errors.map((error) => ` - ${formatValidationPath(error.path)}: ${error.message}`).join("\n"); + const errorMessage2 = `Validation failed for tool "${toolCall.name}": +${errors} + +Received arguments: +${JSON.stringify(toolCall.arguments, null, 2)}`; + throw new Error(errorMessage2); +} + +// node_modules/@earendil-works/pi-durable/dist/truncate.js +var DEFAULT_MAX_LINES = 2e3; +var DEFAULT_MAX_BYTES = 50 * 1024; + +// node_modules/@earendil-works/pi-durable/dist/harness/usage.js +var UsageDoc = defineDoc({ + kind: "pi.usage", + version: 1, + scope: "conversation", + history: "latest", + fork: "initial", + initial: () => ({ models: {}, tools: {} }), + checkpointWhen: () => true +}); +async function recordUsage(tx, conversationId, bucket, key, usage) { + const totals = (await tx.doc(UsageDoc, conversationId))[bucket]; + const total = Object.hasOwn(totals, key) ? totals[key] : void 0; + if (total === void 0) + totals[key] = copyJson(usage, { omitUndefinedProperties: true }); + else + addUsage(total, usage); +} +function addUsage(total, usage) { + total.input += usage.input; + total.output += usage.output; + total.cacheRead += usage.cacheRead; + total.cacheWrite += usage.cacheWrite; + total.totalTokens += usage.totalTokens; + if (usage.cacheWrite1h !== void 0) + total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h; + if (usage.reasoning !== void 0) + total.reasoning = (total.reasoning ?? 0) + usage.reasoning; + total.cost.input += usage.cost.input; + total.cost.output += usage.cost.output; + total.cost.cacheRead += usage.cost.cacheRead; + total.cost.cacheWrite += usage.cost.cacheWrite; + total.cost.total += usage.cost.total; +} +function addUsageState(sum, state) { + for (const bucket of ["models", "tools"]) { + for (const [key, usage] of Object.entries(state[bucket])) { + const total = Object.hasOwn(sum[bucket], key) ? sum[bucket][key] : void 0; + if (total !== void 0) + addUsage(total, usage); + else + Object.defineProperty(sum[bucket], key, { + value: structuredClone(usage), + enumerable: true, + writable: true + }); + } + } +} + +// node_modules/@earendil-works/pi-durable/dist/harness/tool.js +var MADE_NESTED_CALLS = "pi.tool.madeNestedCalls"; +var NestedCallDoc = defineDocFamily({ + kind: "pi.tool.nested-call", + version: 1, + scope: "task", + family: true, + initial: () => ({}), + // The document retires with its task. + checkpointWhen: () => false +}); +var ToolTask = defineTask({ + name: "pi.tool", + version: 3, + initial: () => ({ phase: "call" }), + // Version 1 had only model-issued calls, without `kind`; version 3 added `unoffered`, absent in version 2 inputs. + migrate: (input, checkpoint, fromVersion) => ({ + input: fromVersion === 1 ? { kind: "model", ...input } : input, + checkpoint + }), + phases: { + call: async (task, runtime, context) => { + const call = await readCall(runtime, task.input, context); + if (cutOffTooOften(task, runtime)) + return endCutOff(runtime, task, call, context); + const tool = isUnoffered(task.input) ? void 0 : await resolveTool(runtime, task.input, call.name, context); + if (tool === void 0) { + const error = harnessError("tool_unavailable", `Tool ${call.name} is not available`); + return reject(runtime, task.input, call, void 0, error, context); + } + const prepared = prepare(tool, call.arguments); + const checked = "error" in prepared ? prepared : validate(tool, call, prepared.args); + if ("error" in checked) + return reject(runtime, task.input, call, tool, invalid(checked.error), context); + let args = checked.args; + let block; + await runtime.hooks.each("beforeTool", async (hook2) => { + if (block !== void 0) + return; + try { + const decision = await hook2({ ...call, arguments: args }, runtime, context); + if (decision?.block !== void 0) + block = decision.block; + else if (decision?.arguments !== void 0) + args = decision.arguments; + } catch (error) { + if (runtime.signal.aborted) + throw error; + block = errorMessage(error); + } + }); + if (block !== void 0) { + const blocked = harnessError("blocked", `Tool call blocked: ${block}`); + return reject(runtime, task.input, call, tool, blocked, context); + } + const validated = validate(tool, call, args); + if ("error" in validated) + return reject(runtime, task.input, call, tool, invalid(validated.error), context); + const final = copyJson(validated.args, { omitUndefinedProperties: true }); + await runtime.commit(async (tx) => { + const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); + if (slot !== void 0) + slot.status = "running"; + if (slot !== void 0 && "parentTaskId" in slot) + slot.arguments = final; + return void 0; + }, context, PROGRESS_COMMIT); + await runtime.commit(() => { + const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" }; + return { status: "running", checkpoint: intent }; + }, context); + await run(runtime, task.input, call, tool, final, context); + }, + /** Recovery after intent: rerun only when the stored and the current policy both say `safe`. */ + execute: async (task, runtime, context) => { + const { arguments: args, replay } = task.state.checkpoint; + const call = await readCall(runtime, task.input, context); + if (cutOffTooOften(task, runtime)) + return endCutOff(runtime, task, call, context); + const tool = await resolveTool(runtime, task.input, call.name, context); + if (replay === "safe" && tool?.replay === "safe") { + await runtime.commit(async (tx) => { + const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); + if (slot !== void 0) { + clearProgress(slot); + slot.status = "running"; + if ("parentTaskId" in slot) + slot.arguments = args; + } + return void 0; + }, context); + return run(runtime, task.input, call, tool, args, context); + } + const message = `Tool ${call.name} was interrupted and may have partially run`; + const ending = { status: "failed", message }; + await settleFromSlot(runtime, task.input, call, { tool }, ending, "interrupted", message, context); + } + }, + abort: async (task, runtime, context) => { + const call = await readCall(runtime, task.input, context); + const [code, message] = isUnoffered(task.input) ? ["tool_unavailable", `Tool ${call.name} is not available`] : task.abortReason !== "restart" ? ["aborted", `Tool ${call.name} was aborted`] : task.state.checkpoint.phase === "execute" ? ["interrupted", `Tool ${call.name} was interrupted by a restart and may have partially run`] : ["abandoned", `Tool ${call.name} was not started: its caller ended with a restart`]; + const hooks = (task.restarts ?? 0) < runtime.settings.restartLimit - 1 ? { tool: await toolForLimits(runtime, task.input, call.name, context) } : void 0; + await settleFromSlot(runtime, task.input, call, hooks, { status: "aborted" }, code, message, context); + } +}); +async function endCutOff(runtime, task, call, context) { + const message = `Tool ${call.name} was interrupted ${cutOffCount(task)}; it is not run again`; + const ending = { status: "failed", message, reason: "restart_limit" }; + const hooks = salvageable(task, runtime) ? { tool: await toolForLimits(runtime, task.input, call.name, context) } : void 0; + await settleFromSlot(runtime, task.input, call, hooks, ending, "restart_limit", message, context); +} +async function reject(runtime, input, call, tool, result, context) { + const settled = await afterHarnessResult(runtime, input, call, tool, result, void 0, context); + await settle2(runtime, input, call, COMPLETED, settled, context); +} +async function settleFromSlot(runtime, input, call, hooks, ending, code, message, context) { + const live = await runtime.snapshot(LiveDoc, runtime.conversationId, context); + const { result, truncation } = fromSlot(live === void 0 ? void 0 : toolSlot(live, runtime.taskId), code, message); + const settled = hooks !== void 0 ? await afterHarnessResult(runtime, input, call, hooks.tool, result, truncation, context) : truncation === void 0 ? result : { ...result, diagnostics: [...result.diagnostics ?? [], truncation] }; + await settle2(runtime, input, call, ending, settled, context); +} +async function toolForLimits(runtime, input, name, context) { + if (isUnoffered(input)) + return void 0; + return resolveTool(runtime, input, name, context).catch(() => void 0); +} +function isUnoffered(input) { + return input.kind === "model" && input.unoffered === true; +} +async function resolveTool(runtime, input, name, context) { + const agent = await runtime.agent(context); + return (input.kind === "model" ? agent.tools : agent.callable).find((tool) => tool.name === name); +} +function outputLimits(tool) { + return { + maxBytes: tool?.outputLimits?.maxBytes ?? DEFAULT_MAX_BYTES, + maxLines: tool?.outputLimits?.maxLines ?? DEFAULT_MAX_LINES, + retain: tool?.outputLimits?.retain ?? "head" + }; +} +async function readCall(runtime, input, context) { + if (input.kind === "nested") { + context.telemetry.setAttributes({ + "tool.name": input.call.name, + "tool.call_id": input.call.id + }); + return { ...input.call, parent: { taskId: input.parent, callId: input.parentCallId } }; + } + const entry2 = await runtime.entry(AssistantEntry, input.assistant, context); + const message = entry2?.model?.[0]; + const call = message?.role === "assistant" ? message.content.find((content) => content.type === "toolCall" && content.id === input.callId) : void 0; + if (call === void 0) + throw new Error(`Entry ${input.assistant} has no tool call ${input.callId}`); + context.telemetry.setAttributes({ + "tool.name": call.name, + "tool.call_id": call.id + }); + return call; +} +function prepare(tool, args) { + if (tool.prepareArguments === void 0) + return { args }; + try { + return { args: tool.prepareArguments(args) }; + } catch (error) { + return { error: errorMessage(error) }; + } +} +function validate(tool, call, args) { + try { + return { args: validateToolArguments(tool, { ...call, arguments: args }) }; + } catch (error) { + return { error: errorMessage(error) }; + } +} +function invalid(message) { + return harnessError("invalid_arguments", message); +} +async function run(runtime, input, call, tool, args, context) { + const limits = outputLimits(tool); + const reported = { output: new OutputBuffer(limits), limits, diagnostics: [], details: void 0 }; + const streams = input.kind === "model" || input.progress !== false; + const progress = publishProgress(runtime, reported, streams, context); + const admissions = []; + const resumes = tool.replay === "safe"; + let sequence2 = 0; + let madeNestedMemo; + let ended = false; + const assertLive = () => { + if (ended) + throw new Error(`Tool call ${call.id} has settled`); + }; + let env; + const api = { + taskId: runtime.taskId, + conversationId: runtime.conversationId, + callId: call.id, + registry: runtime.registry, + agent: runtime.agent, + get models() { + return runtime.models; + }, + get env() { + return env; + }, + now: () => runtime.now(), + output: (chunk, skipped) => { + assertLive(); + if (reported.output.push(chunk, skipped)) + progress.mark(); + }, + outputWindow: limits.retain === "tail" ? { + maxBytes: limits.maxBytes, + maxLines: limits.maxLines, + minIntervalMs: runtime.settings.progress.outputIntervalMs, + bytesPerSecond: PROGRESS_BYTES_PER_SECOND + } : void 0, + retainedOutput: () => { + assertLive(); + const retained = reported.output.snapshot(); + return { text: retained.text, truncated: retained.droppedBytes > 0 }; + }, + diagnostic: (diagnostic) => { + assertLive(); + reported.diagnostics.push(copyJson(diagnostic, { omitUndefinedProperties: true })); + progress.mark(); + }, + details: async (value2, detailsContext) => { + assertLive(); + detailsContext.abortSignal?.throwIfAborted(); + reported.details = copyJson(value2, { omitUndefinedProperties: true }); + const committed = progress.markAndWait(); + committed.catch(() => { + }); + return awaitWithContext(committed, detailsContext); + }, + commit: async (change, commitContext, options) => { + let result2; + await runtime.commit(async (tx) => { + result2 = await change(tx); + return void 0; + }, commitContext, options); + return result2; + }, + memo: runtime.memo, + createTask: async (task, input2, options, taskContext) => { + const child = options.ownership.kind === "task" && !resumes ? { abandonOnRestart: true, ...options } : options; + let id; + await runtime.commit(async (tx) => { + id = await tx.createTask(task, input2, child); + return void 0; + }, taskContext); + return id; + }, + getTask: runtime.getTask, + waitForTask: runtime.waitForTask, + conversation: runtime.conversation, + snapshot: runtime.snapshot, + snapshotAsOf: runtime.snapshotAsOf, + watchDoc: runtime.watchDoc, + executeTool: async (name, toolArgs, callContext, options = {}) => { + assertLive(); + const key = options.key ?? String(++sequence2); + if (options.key !== void 0) + checkKey(options.key); + madeNestedMemo ??= runtime.memo(MADE_NESTED_CALLS, true, callContext).catch((error) => { + madeNestedMemo = void 0; + throw error; + }); + const admission = madeNestedMemo.then(() => startNestedCall(runtime, call.id, name, toolArgs, { key, progress: options.progress, abandonOnRestart: !resumes }, callContext)); + admissions.push(admission); + const id = await admission; + const settled = await runtime.waitForTask(id, callContext); + const stored = (await runtime.snapshot(NestedCallDoc, runtime.taskId, key, callContext))?.result; + return stored === void 0 ? fallbackResult(id, name, settled.state.outcome) : copyJson(stored); + } + }; + let result; + let ending = COMPLETED; + let durationMs; + try { + env = await runtime.env(context); + const startedAt = performance.now(); + try { + result = await tool.execute(args, api, context); + } finally { + durationMs = Math.round(performance.now() - startedAt); + } + } catch (error) { + if (runtime.signal.aborted) { + ended = true; + for (const waiter of await progress.stop()) + waiter.reject(error); + throw error; + } + result = { isError: true, diagnostics: [toolDiagnostic("tool_error", errorMessage(error))] }; + ending = { status: "failed", message: `Tool ${call.name} threw` }; + } + ended = true; + await Promise.allSettled(admissions); + reported.output.end(); + const pending = await progress.stop(); + try { + const settled = await finalResult(runtime, input, call, tool, result, reported, context); + await settle2(runtime, input, call, ending, settled, context, durationMs); + } catch (error) { + for (const waiter of pending) + waiter.reject(error); + throw error; + } + for (const waiter of pending) + waiter.resolve(); +} +function progressClock(runtime) { + let last = 0; + return { + now: () => { + try { + last = runtime.now(); + } catch { + } + return last; + }, + setTimer: (callback, delayMs) => runtime.setTimer(callback, delayMs) + }; +} +function publishProgress(runtime, reported, enabled, context) { + let written = { text: "", details: void 0, diagnostics: 0 }; + return new Progress(async () => { + if (!enabled) + return 0; + const snapshot = reported.output.snapshot(); + const current = { text: snapshot.text, details: reported.details, diagnostics: reported.diagnostics.length }; + const added = reported.diagnostics.slice(written.diagnostics, current.diagnostics); + const detailsChanged = current.details !== written.details; + let bytes = 0; + if (snapshot.text !== written.text) { + const shared = snapshot.text.startsWith(written.text) ? written.text.length : overlap(written.text, snapshot.text, 65536); + bytes += utf8ByteLength(snapshot.text.slice(shared)); + } + if (detailsChanged) + bytes += utf8ByteLength(JSON.stringify(current.details ?? null)); + if (added.length > 0) + bytes += utf8ByteLength(JSON.stringify(added)); + await runtime.commit(async (tx) => { + const slot = toolSlot(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); + if (slot === void 0) + return void 0; + if ((slot.output ?? "") !== snapshot.text) + slot.output = snapshot.text; + if (snapshot.droppedBytes > 0) + slot.droppedBytes = snapshot.droppedBytes; + if (snapshot.droppedLines > 0) + slot.droppedLines = snapshot.droppedLines; + if (detailsChanged && current.details !== void 0) { + assignJson(slot, "details", current.details); + } + if (added.length > 0) { + if (slot.diagnostics === void 0) + slot.diagnostics = []; + for (const diagnostic of added) + slot.diagnostics.push(diagnostic); + } + return void 0; + }, context, PROGRESS_COMMIT); + written = current; + return bytes; + }, (error) => { + if (!runtime.signal.aborted) + runtime.report(error); + }, runtime.settings.progress.outputIntervalMs, progressClock(runtime)); +} +async function finalResult(runtime, input, call, tool, result, reported, context) { + const harness2 = []; + const retained = result.output === void 0 ? reported.output.snapshot() : void 0; + const output = retained === void 0 ? result.output : retained.text === "" ? [] : [{ type: "text", text: retained.text }]; + let final = { + ...result, + output, + details: result.details === void 0 ? reported.details : result.details, + diagnostics: [...reported.diagnostics, ...result.diagnostics ?? []] + }; + await runtime.hooks.each("afterTool", async (hook2) => { + final = await hook2(call, final, runtime, context) ?? final; + }); + if (retained !== void 0 && retained.droppedBytes > 0 && final.output === output) { + harness2.push(truncated(retained, reported.limits.retain)); + } + const bounded = boundContent(final.output ?? [], reported.limits); + if (bounded.droppedBytes > 0) + harness2.push(truncated(bounded, reported.limits.retain)); + const broken = structuredOutputError(tool, final); + if (broken !== void 0) { + const { structuredOutput: _dropped, ...rest } = final; + if (input.kind === "nested") { + final = { ...rest, isError: true }; + harness2.push(toolDiagnostic("invalid_structured_output", broken)); + } else { + final = rest; + const extension = await runtime.agent(context).then((agent) => toolExtension(agent, tool.name), () => void 0); + runtime.report(new Error(broken), { kind: "extension", ...optionalExtension(extension) }); + } + } + if (input.kind === "nested" && tool.structuredOutputSchema === void 0) { + final = { ...final, structuredOutput: outputValue(bounded.content) }; + } + if (input.kind === "nested" && tool.structuredOutputSchema !== void 0 && final.isError === true && final.structuredOutput === void 0 && ![...final.diagnostics ?? [], ...harness2].some((diagnostic) => diagnostic.severity === "error")) { + const text = bounded.content.flatMap((item) => item.type === "text" ? [item.text] : []).join("\n"); + if (text !== "") + harness2.push(toolDiagnostic("tool_error", text)); + } + return { ...final, output: bounded.content, diagnostics: [...final.diagnostics ?? [], ...harness2] }; +} +async function afterHarnessResult(runtime, input, call, tool, result, truncation, context) { + let final = result; + await runtime.hooks.each("afterTool", async (hook2) => { + final = await hook2(call, final, runtime, context) ?? final; + }); + const harness2 = []; + if (truncation !== void 0 && final.output === result.output) + harness2.push(truncation); + const limits = outputLimits(tool); + const bounded = boundContent(final.output ?? [], limits); + if (bounded.droppedBytes > 0) + harness2.push(truncated(bounded, limits.retain)); + const { structuredOutput: _dropped, ...rest } = final; + const value2 = input.kind === "nested" && final.isError !== true && tool?.structuredOutputSchema === void 0 ? { structuredOutput: outputValue(bounded.content) } : {}; + return { ...rest, ...value2, output: bounded.content, diagnostics: [...rest.diagnostics ?? [], ...harness2] }; +} +var structuredValidators = /* @__PURE__ */ new WeakMap(); +function structuredOutputError(tool, result) { + const schema = tool.structuredOutputSchema; + const value2 = result.structuredOutput; + if (schema === void 0) { + return value2 === void 0 ? void 0 : `Tool ${tool.name} returned structuredOutput but declares no structuredOutputSchema`; + } + if (value2 === void 0) { + return result.isError === true ? void 0 : `Tool ${tool.name} returned no structuredOutput`; + } + let validator = structuredValidators.get(schema); + if (validator === void 0) { + validator = compile(schema, { formats, unresolvedRefs: "reject" }); + structuredValidators.set(schema, validator); + } + if (validator.check(value2)) + return void 0; + const [first] = validator.errors(value2); + const path = first?.path.slice(1).split("/").map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")).join(".") || "root"; + return `Tool ${tool.name} returned structuredOutput that does not match its schema: ${path}: ${first?.message ?? "invalid"}`; +} +async function settle2(runtime, input, call, ending, result, context, durationMs) { + const nestedIds = (await runtime.ownedTasks(context)).filter((record) => record.kind === ToolTask.definition.name && record.input.kind === "nested").map((record) => record.id); + const madeNested = await runtime.memo(MADE_NESTED_CALLS, context) === true; + await Promise.all(nestedIds.map((id) => runtime.abortOwned(id, context))); + await runtime.commit(async (tx) => { + const conversationId = runtime.conversationId; + const live = await tx.doc(LiveDoc, conversationId); + const slot = toolSlot(live, runtime.taskId); + let settled; + if (input.kind === "nested") { + const nested = nestedResult(runtime.taskId, result, durationMs); + if (nested.usage !== void 0) + await recordUsage(tx, conversationId, "tools", call.name, nested.usage); + const state = await tx.doc(NestedCallDoc, input.parent, input.key, null); + if (state.taskId !== runtime.taskId) + throw new Error(`Nested call ${call.id} is not in its caller's index`); + state.result = nested; + if (slot !== void 0 && "parentTaskId" in slot) { + finishSlot(slot); + slot.summary = summaryOf(nested, result.output ?? []); + } + settled = { kind: "nested" }; + } else { + const timing = { timestamp: runtime.now(), durationMs }; + const entry2 = await appendToolResult(tx, conversationId, call, result, timing); + if (slot !== void 0 && !("parentTaskId" in slot)) { + finishSlot(slot); + slot.entry = entry2.id; + } + const control = result.control === void 0 || ending.status !== "completed" ? {} : { control: copyJson(result.control, { omitUndefinedProperties: true }) }; + settled = { kind: "model", entryId: entry2.id, ...control }; + } + if (madeNested) + removeNestedSlots(live, runtime.taskId); + if (ending.status === "aborted") + return { status: "terminal", outcome: { status: "aborted", result: settled } }; + if (ending.status === "failed") { + const error = { + message: ending.message, + ...ending.reason === void 0 ? {} : { detail: { reason: ending.reason } } + }; + return { status: "terminal", outcome: { status: "failed", error, result: settled } }; + } + return { status: "terminal", outcome: { status: "completed", result: settled } }; + }, context); +} +function nestedResult(taskId, result, durationMs) { + const nested = { + taskId, + structuredOutput: result.structuredOutput, + isError: result.isError ?? false, + details: result.details, + diagnostics: result.diagnostics ?? [], + usage: result.usage, + durationMs + }; + return copyJson(nested, { + omitUndefinedProperties: true + }); +} +async function startNestedCall(runtime, parentCallId, name, args, options, context) { + const key = options.key; + const call = { + type: "toolCall", + id: `${parentCallId}/${key}`, + name, + arguments: copyJson(args, { omitUndefinedProperties: true }) + }; + let id; + let replayed = false; + await runtime.commit(async (tx) => { + const state = await tx.doc(NestedCallDoc, runtime.taskId, key, null); + const existing = state.taskId; + if (existing !== void 0) { + const input2 = (await tx.task(existing))?.input; + const same = input2?.kind === "nested" && input2.call.name === name && jsonEqual2(input2.call.arguments, call.arguments); + if (!same) + throw new Error(`Nested call ${call.id} was already made with another tool or other arguments`); + id = existing; + replayed = state.result !== void 0; + return void 0; + } + const input = { + kind: "nested", + parent: runtime.taskId, + parentCallId, + key, + call, + ...options.progress === false ? { progress: false } : {} + }; + const created = await tx.createTask(ToolTask, input, { + ownership: { kind: "task", taskId: runtime.taskId }, + abandonOnRestart: options.abandonOnRestart + }); + state.taskId = created; + const live = await tx.doc(LiveDoc, runtime.conversationId); + live.nestedTools ??= []; + const slot = { + callId: call.id, + parentCallId, + parentTaskId: runtime.taskId, + name, + taskId: created, + arguments: call.arguments, + status: "pending" + }; + live.nestedTools.push(slot); + id = created; + return void 0; + }, context); + if (replayed) { + context.telemetry.addEvent("pi.durable.replay", { "tool.name": name }); + } + return id; +} +function outputValue(output) { + if (output.length === 0) + return ""; + if (output.length > 1) + return output; + const [only] = output; + return only.type === "text" ? only.text : only; +} +function fallbackResult(taskId, name, outcome) { + const message = outcome.status === "faulted" ? `Tool ${name} failed: ${outcome.error.message}` : outcome.status === "orphaned" ? `Tool ${name} could not resume: ${outcome.reason}` : `Tool ${name} ended without a result`; + const error = harnessError(outcome.status, message); + return { taskId, isError: true, diagnostics: error.diagnostics ?? [] }; +} +var COMPLETED = { status: "completed" }; +function fromSlot(slot, code, message) { + const droppedBytes = slot?.droppedBytes ?? 0; + const result = { + output: slot?.output === void 0 || slot.output === "" ? [] : [{ type: "text", text: slot.output }], + isError: true, + ...slot?.details === void 0 ? {} : { details: slot.details }, + diagnostics: [...slot?.diagnostics ?? [], toolDiagnostic(code, message)] + }; + if (droppedBytes === 0) + return { result }; + return { result, truncation: truncated({ droppedBytes, droppedLines: slot?.droppedLines ?? 0 }) }; +} +function harnessError(code, message) { + return { output: [], isError: true, diagnostics: [toolDiagnostic(code, message)] }; +} +function toolDiagnostic(code, message) { + return { severity: "error", code, message }; +} +function truncated(dropped, retain) { + const kept = retain === void 0 ? "" : ` to its ${retain === "head" ? "beginning" : "end"}`; + return { + severity: "warn", + code: "truncated", + message: `Output truncated${kept}: ${dropped.droppedLines} lines, ${dropped.droppedBytes} bytes dropped` + }; +} +var MAX_SUMMARY_ERROR_CHARS = 500; +function summaryOf(result, output) { + const error = result.isError ? ownedSlice2(errorTextOf(result, output), 0, MAX_SUMMARY_ERROR_CHARS) : ""; + return { + isError: result.isError, + ...result.durationMs === void 0 ? {} : { durationMs: result.durationMs }, + ...result.usage === void 0 ? {} : { usage: result.usage }, + ...error === "" ? {} : { error } + }; +} +function errorTextOf(result, output) { + const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error"); + if (errors.length > 0) + return errors.map((diagnostic) => diagnostic.message).join("\n"); + return output.flatMap((item) => item.type === "text" ? [item.text] : []).join("\n"); +} +async function appendToolResult(tx, conversationId, call, result, meta) { + const { timestamp, durationMs } = meta; + const diagnostics = [...result.diagnostics ?? []]; + const content = [...result.output ?? []]; + if (diagnostics.length > 0) + content.push({ type: "text", text: renderDiagnostics(diagnostics) }); + const message = { + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content, + ...result.details === void 0 ? {} : { details: result.details }, + ...result.usage === void 0 ? {} : { usage: result.usage }, + isError: result.isError ?? false, + ...durationMs === void 0 ? {} : { durationMs }, + timestamp + }; + if (result.usage !== void 0) + await recordUsage(tx, conversationId, "tools", call.name, result.usage); + return tx.appendEntry(ToolResultEntry, conversationId, { model: [message], data: { diagnostics } }); +} +function renderDiagnostics(diagnostics) { + return ` +${diagnostics.map((diagnostic) => `[${diagnostic.severity}] ${diagnostic.message}`).join("\n")} +`; +} +function boundContent(content, limits) { + const texts = content.filter((item) => item.type === "text"); + const bounded = boundOutput(texts.map((item) => item.text).join(""), limits); + if (bounded.droppedBytes === 0) + return { content, droppedBytes: 0, droppedLines: 0 }; + const keep = limits.retain === "head" ? texts[0] : texts.at(-1); + const result = []; + for (const item of content) { + if (item.type !== "text") + result.push(item); + else if (item === keep) + result.push({ ...item, text: bounded.text }); + } + return { content: result, droppedBytes: bounded.droppedBytes, droppedLines: bounded.droppedLines }; +} +function checkKey(key) { + if (key === "" || key.includes("/") || key === "__proto__" || /^[1-9][0-9]*$/.test(key)) { + throw new Error(`Nested call key ${JSON.stringify(key)} must be non-empty, without "/", not "__proto__", and not a positive integer`); + } +} +function jsonEqual2(a, b) { + if (a === b) + return true; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) + return false; + if (Array.isArray(a) || Array.isArray(b)) { + return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => jsonEqual2(item, b[i])); + } + const keys = Object.keys(a); + return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && jsonEqual2(a[key], b[key])); +} + +// node_modules/@earendil-works/pi-durable/dist/harness/generation.js +var DEFAULT_POLL_AFTER_MS = 5e3; +var GenerationTask = defineTask({ + name: "pi.generation", + version: 2, + initial: () => ({ phase: "prepare", attempt: 1 }), + // Version 2 leaves calls to tools not offered in a sequential round's `pending`, flagged `unoffered` in their slots. + // A version 1 Harness would start their tool tasks unflagged, resolving the name, so it leaves version 2 blocked + // instead. A version 1 checkpoint has no such calls and is kept as it is. + migrate: (input, checkpoint) => ({ + input, + checkpoint + }), + phases: { + /** + * Render the system prompt and tool loadout and append the positional `pi.system` entries they need, then move to + * `request`. The agent and settings resolved here are fixed for this request. Only the Harness writes model + * context to a busy conversation, so the context read here is still current at the commit; display-only entries + * may land meanwhile. + */ + prepare: async (task, runtime, context) => { + if (cutOffTooOften(task, runtime)) + return endCutOff2(runtime, task, context); + const { conversationId } = runtime; + const agent = await runtime.agent(context); + const settings = runtime.settings; + const { model: model2, thinkingLevel } = agent; + const resolved = model2 === void 0 ? void 0 : runtime.models.getModel(model2.provider, model2.modelId); + if (model2 === void 0 || resolved === void 0) + return failNoModel(runtime, model2, context); + const { attempt, compacted, overflow, threshold: blocked } = task.state.checkpoint; + if (compacted !== void 0 && overflow !== void 0) { + const [outcome] = await runtime.outcomes([compacted], context); + if (outcome?.status !== "completed" || outcome.result.entryId === void 0) { + return failModelError(runtime, overflow, context); + } + } + if (compacted !== void 0 && blocked === true) { + const [outcome] = await runtime.outcomes([compacted], context); + if (outcome !== void 0 && outcome.status !== "completed") { + return failModelError(runtime, compactionFailure(outcome), context); + } + } + const view2 = await runtime.context(conversationId, context); + const last = view2.messages.findLast((message) => message.role !== "system"); + if (last?.role !== "user" && last?.role !== "toolResult") + return failNothingToAnswer(runtime, context); + const shown = replaySections(view2.messages); + let env; + try { + env = await runtime.env(context); + } catch (error) { + if (context.abortSignal?.aborted) + throw error; + runtime.report(error, { kind: "host", callback: "env" }); + } + const input = { conversationId, agent, env, shown: Object.fromEntries(shown), read: runtime }; + const desired = await renderSections(agent.sections, input, shown, (error, section2) => runtime.report(error, { + kind: "extension", + ...optionalExtension(sectionExtension(agent, section2.key)) + }), context); + const entries2 = planSystemEntries(view2, desired, agent.tools, runtime.now()); + const threshold = compacted === void 0 ? thresholdCompaction(view2, entries2, resolved.contextWindow, compactionPolicy(settings, model2)) : void 0; + if (threshold === "blocking") { + await runtime.commit(async (tx) => { + const child = await createCompaction(tx, conversationId, { reason: "threshold" }, runtime.taskId); + const checkpoint = { phase: "prepare", attempt, compacted: child, threshold: true }; + return { status: "waiting", checkpoint, on: [child], policy: "allSettled" }; + }, context); + return; + } + await runtime.commit(async (tx) => { + let cutoff = await tx.latestEntryId(conversationId); + for (const entry2 of entries2) + cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry2)).id; + if (threshold === "background" && (await tx.doc(LiveDoc, conversationId)).compactions === void 0) { + await createCompaction(tx, conversationId, { reason: "threshold" }); + } + const request2 = { + attempt, + ...compacted === void 0 ? {} : { compacted }, + model: model2, + thinkingLevel, + streamOptions: settings.stream, + cutoff + }; + return { status: "running", checkpoint: { phase: "request", ...request2 } }; + }, context); + }, + request: async (task, runtime, context) => { + if (cutOffTooOften(task, runtime)) + return endCutOff2(runtime, task, context); + if (task.restarts !== void 0) + return interrupted(runtime, task.state.checkpoint, context); + const { attempt, compacted, model: ref, thinkingLevel, streamOptions, cutoff } = task.state.checkpoint; + const conversationId = runtime.conversationId; + await runtime.commit(async (tx) => { + const live = await tx.doc(LiveDoc, conversationId); + await convertPartial(tx, live, conversationId); + live.generation = { attempt }; + return void 0; + }, context, PROGRESS_COMMIT); + const model2 = runtime.models.getModel(ref.provider, ref.modelId); + if (model2 === void 0) + return failNoModel(runtime, ref, context); + const view2 = await runtime.context(conversationId, context, { at: cutoff }); + let messages = view2.messages; + await runtime.hooks.each("beforeRequest", async (hook2) => { + const replaced = await hook2({ messages }, runtime, context); + if (replaced !== void 0) + messages = replaced.messages; + }); + const options = { + ...streamOptions, + signal: runtime.signal, + telemetry: context.telemetry, + sessionId: await ensureProviderSessionId(runtime, context), + ...thinkingLevel === "off" ? {} : { reasoning: thinkingLevel }, + onPayload: (payload) => providerPayload(runtime, model2, payload, context) + }; + const message = await streamResponse(runtime, model2, messages, options, attempt, context); + const request2 = { attempt, compacted, model: ref, thinkingLevel, cutoff, messages: view2.messages }; + await classify(runtime, request2, message, context); + }, + /** After the backoff's sleep; a run before `until` (an early wake, or a record of an earlier version) sleeps again. */ + retry: async (task, runtime, context) => { + const { attempt, compacted, until, poll } = task.state.checkpoint; + if (cutOffTooOften(task, runtime)) + return endCutOff2(runtime, task, context); + if (until > runtime.now()) + return runtime.sleepUntil(until, task.state.checkpoint, context); + await runtime.commit(async (tx) => { + const live = await tx.doc(LiveDoc, runtime.conversationId); + const kept = compacted === void 0 ? {} : { compacted }; + if (poll !== void 0) { + const pollAt = runtime.now(); + live.generation = { attempt: attempt + 1, deferred: { pollAt } }; + return { + status: "running", + checkpoint: { phase: "poll", attempt: attempt + 1, ...kept, ...poll, pollAt } + }; + } + live.generation = { attempt: attempt + 1 }; + return { status: "running", checkpoint: { phase: "prepare", attempt: attempt + 1, ...kept } }; + }, context); + }, + /** After the sleep until `pollAt`; a run before it (an early wake, or a record of an earlier version) sleeps again. */ + poll: async (task, runtime, context) => { + const { attempt, compacted, model: ref, thinkingLevel, cutoff, handle, pollAt } = task.state.checkpoint; + if (cutOffTooOften(task, runtime)) + return endCutOff2(runtime, task, context); + if (task.restarts !== void 0) + return interrupted(runtime, task.state.checkpoint, context); + if (pollAt > runtime.now()) + return runtime.sleepUntil(pollAt, task.state.checkpoint, context); + const model2 = runtime.models.getModel(ref.provider, ref.modelId); + if (model2 === void 0) + return failNoModel(runtime, ref, context); + const message = await runtime.models.fetchDeferred(model2, handle, { + signal: runtime.signal, + telemetry: context.telemetry + }); + const request2 = { attempt, compacted, model: ref, thinkingLevel, cutoff, pollAt }; + await classify(runtime, request2, message, context); + }, + tools: async (task, runtime, context) => { + if (cutOffTooOften(task, runtime)) + return endCutOff2(runtime, task, context); + const { assistant, tools, pending } = task.state.checkpoint; + const [next, ...rest] = pending; + if (next === void 0) + return finishToolRound(runtime, assistant, tools, context); + await runtime.commit(async (tx) => { + const live = await tx.doc(LiveDoc, runtime.conversationId); + const slot = live.tools?.find((slot2) => slot2.callId === next && slot2.taskId === void 0); + const taskId = await createToolTask(tx, runtime, assistant, next, slot?.unoffered); + if (slot !== void 0) + slot.taskId = taskId; + const checkpoint = { + phase: "tools", + assistant, + tools: [...tools, taskId], + pending: rest + }; + return { status: "waiting", checkpoint, on: [taskId], policy: "allSettled" }; + }, context); + }, + /** + * Ask the `afterRun` hooks for tasks and create them, as the generation's children, in the commit that moves to + * `drain`; without any, end the run now. + */ + finish: async (task, runtime, context) => { + const { run: run2 } = task.state.checkpoint; + if (cutOffTooOften(task, runtime)) + return endAnsweredCutOff(runtime, task, run2.answer, context); + const prepared = []; + await runtime.hooks.each("afterRun", async (hook2) => { + const asked = await hook2(run2, runtime, context); + if (asked !== void 0) + prepareRunEndTasks(runtime, asked.tasks, prepared); + }); + if (prepared.length === 0) + return endAnsweredRun(runtime, run2.answer, context); + await runtime.commit(async (tx) => { + const tasks = []; + for (const { task: task2, input } of prepared) { + tasks.push(await tx.createTask(task2, input, { ownership: { kind: "task", taskId: runtime.taskId } })); + } + const live = await tx.doc(LiveDoc, runtime.conversationId); + if (live.run?.taskId === runtime.taskId && live.run.afterRun !== void 0) + live.run.afterRun.tasks = tasks; + const checkpoint = { phase: "drain", answer: run2.answer, tasks }; + return { status: "waiting", checkpoint, on: tasks, policy: "allSettled" }; + }, context); + }, + /** The after-run tasks are terminal, whatever their outcomes: end the run. */ + drain: async (task, runtime, context) => { + const { answer: answer2 } = task.state.checkpoint; + if (cutOffTooOften(task, runtime)) + return endAnsweredCutOff(runtime, task, answer2, context); + return endAnsweredRun(runtime, answer2, context); + } + }, + abort: async (task, runtime, context) => { + const checkpoint = task.state.checkpoint; + const polled = deferredOf(checkpoint); + if (polled !== void 0) + await cancelDeferred(runtime, polled, context); + const conversationId = runtime.conversationId; + const unstarted = checkpoint.phase === "tools" ? await readCalls(runtime, checkpoint.assistant, checkpoint.pending, context) : []; + await runtime.commit(async (tx) => { + const live = await tx.doc(LiveDoc, conversationId); + await convertPartial(tx, live, conversationId); + for (const call of unstarted) { + const unoffered = live.tools?.find((slot) => slot.callId === call.id)?.unoffered === true; + const result = unoffered ? harnessError("tool_unavailable", `Tool ${call.name} is not available`) : harnessError("aborted", `Tool ${call.name} was aborted`); + await appendToolResult(tx, conversationId, call, result, { timestamp: runtime.now() }); + } + endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "aborted" }); + return { status: "terminal", outcome: { status: "aborted" } }; + }, context); + } +}); +async function readCalls(runtime, assistant, callIds, context) { + const message = (await runtime.entry(AssistantEntry, assistant, context))?.model?.[0]; + const calls = message?.role === "assistant" ? message.content.filter((content) => content.type === "toolCall") : []; + return callIds.flatMap((id) => calls.filter((call) => call.id === id).slice(0, 1)); +} +function createToolTask(tx, runtime, assistant, callId, unoffered) { + return tx.createTask(ToolTask, { kind: "model", assistant, callId, ...unoffered === void 0 ? {} : { unoffered } }, { ownership: { kind: "task", taskId: runtime.taskId } }); +} +function thresholdCompaction(view2, planned, contextWindow, policy) { + if (!policy.enabled || contextWindow <= 0) + return void 0; + const tokens = estimateContext(view2, planned.flatMap((entry2) => entry2.model ?? [])); + const blocking = contextWindow - policy.reserveTokens; + const background = blocking - policy.backgroundTokens; + const over = tokens > blocking ? "blocking" : policy.backgroundTokens > 0 && tokens > background ? "background" : void 0; + if (over === void 0 || selectCut(view2, policy.keepRecentTokens) === void 0) + return void 0; + return over; +} +function compactionFailure(outcome) { + switch (outcome.status) { + case "failed": + case "faulted": + return `Compaction failed: ${outcome.error.message}`; + case "orphaned": + return `Compaction failed: ${outcome.reason}`; + case "aborted": + return "Compaction was aborted"; + } +} +async function failModelError(runtime, text, context) { + await runtime.commit(async (tx) => { + const failure = await prepareFailure(tx, runtime); + const live = await tx.doc(LiveDoc, runtime.conversationId); + await endFailedRun(tx, runtime, live, failure, { status: "unanswered", reason: "model_error", detail: text }); + return { + status: "terminal", + outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } } + }; + }, context); +} +async function interrupted(runtime, checkpoint, context) { + const { attempt, compacted } = checkpoint; + const policy = runtime.settings.retry; + const retry = policy.enabled && attempt <= policy.maxRetries; + const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0; + const polled = checkpoint.phase === "poll" ? checkpoint : void 0; + const text = polled === void 0 ? `The host stopped while the model was answering, on attempt ${attempt}` : `The host stopped while the deferred answer was fetched, on attempt ${attempt}`; + if (!retry && polled !== void 0) + await cancelDeferred(runtime, polled, context); + await runtime.commit(async (tx) => { + const failure = retry ? void 0 : await prepareFailure(tx, runtime); + const live = await tx.doc(LiveDoc, runtime.conversationId); + await convertPartial(tx, live, runtime.conversationId); + if (failure === void 0) { + live.generation = { attempt, retry: { at: until, error: text } }; + const poll = polled === void 0 ? {} : { + poll: { + model: polled.model, + ...polled.thinkingLevel === void 0 ? {} : { thinkingLevel: polled.thinkingLevel }, + cutoff: polled.cutoff, + handle: polled.handle + } + }; + const next = { phase: "retry", attempt, ...compacted === void 0 ? {} : { compacted }, until, ...poll }; + return { status: "sleeping", checkpoint: next, until }; + } + await endFailedRun(tx, runtime, live, failure, { status: "unanswered", reason: "interrupted", detail: text }); + return { + status: "terminal", + outcome: { status: "failed", error: { message: text, detail: { reason: "interrupted" } } } + }; + }, context); +} +async function endCutOff2(runtime, task, context) { + const checkpoint = task.state.checkpoint; + const text = `The generation was interrupted ${cutOffCount(task)} in its ${checkpoint.phase} phase`; + const salvage = salvageable(task, runtime); + const polled = deferredOf(checkpoint); + if (salvage && polled !== void 0) + await cancelDeferred(runtime, polled, context); + await runtime.commit(async (tx) => { + const failure = await prepareFailure(tx, runtime); + const live = await tx.doc(LiveDoc, runtime.conversationId); + if (salvage) + await convertPartial(tx, live, runtime.conversationId); + await endFailedRun(tx, runtime, live, failure, { status: "unanswered", reason: "restart_limit", detail: text }); + return { + status: "terminal", + outcome: { status: "failed", error: { message: text, detail: { reason: "restart_limit" } } } + }; + }, context); +} +function deferredOf(checkpoint) { + if (checkpoint.phase === "poll") + return checkpoint; + return checkpoint.phase === "retry" ? checkpoint.poll : void 0; +} +async function cancelDeferred(runtime, checkpoint, context) { + const model2 = runtime.models.getModel(checkpoint.model.provider, checkpoint.model.modelId); + if (model2 === void 0) + return; + try { + await runtime.models.cancelDeferred(model2, checkpoint.handle, { + signal: runtime.signal, + telemetry: context.telemetry + }); + } catch (error) { + runtime.report(error, { kind: "host", callback: "models" }); + } +} +async function failNoModel(runtime, ref, context) { + const message = ref === void 0 ? "No model is configured" : `Model ${ref.provider}/${ref.modelId} is not available`; + await runtime.commit(async (tx) => { + const failure = await prepareFailure(tx, runtime); + const live = await tx.doc(LiveDoc, runtime.conversationId); + await endFailedRun(tx, runtime, live, failure, { + status: "unanswered", + reason: "no_model", + detail: message, + settlement: { status: "unanswered", reason: "no_model" } + }); + return { status: "terminal", outcome: { status: "failed", error: { message, detail: { reason: "no_model" } } } }; + }, context); +} +async function failNothingToAnswer(runtime, context) { + const message = "The conversation has nothing to answer"; + await runtime.commit(async (tx) => { + const failure = await prepareFailure(tx, runtime); + const live = await tx.doc(LiveDoc, runtime.conversationId); + await endFailedRun(tx, runtime, live, failure, { status: "unanswered", reason: "nothing_to_answer" }); + const error = { message, detail: { reason: "nothing_to_answer" } }; + return { status: "terminal", outcome: { status: "failed", error } }; + }, context); +} +async function prepareFailure(tx, runtime) { + const settings = runtime.settings; + const policy = settings.afterFailedRun; + if (policy !== "continue") + return { policy }; + return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings) }; +} +async function endFailedRun(tx, runtime, live, failure, end) { + const conversationId = runtime.conversationId; + endRun(tx, live, runtime.taskId, end); + if (failure.policy === "withdraw") + await withdrawQueuedInputs(tx, conversationId, "run_failed"); + if (failure.boundary === void 0) + return; + const { users, first } = await applyBoundary(tx, failure.boundary, "final", runtime.now()); + if (users.length > 0) + await startRun(tx, conversationId, live, { inputs: users, first }, { keepFailure: true }); +} +async function convertPartial(tx, live, conversationId) { + const partial = live.generation?.message; + if (partial === void 0) + return; + const message = copyJson(partial); + await appendAssistant(tx, conversationId, { ...message, stopReason: "aborted" }); +} +async function providerPayload(runtime, model2, payload, context) { + let current = payload; + await runtime.hooks.each("beforeProviderRequest", async (hook2) => { + const replaced = await hook2({ model: model2, payload: current }, runtime, context); + if (replaced !== void 0) + current = replaced.payload; + }); + return current; +} +async function streamResponse(runtime, model2, messages, options, attempt, context) { + const interval = runtime.settings.progress.partialIntervalMs; + let pending; + let timer; + let inFlight; + let stopped = false; + const changes = new PartialChanges(); + const flush = () => { + timer = void 0; + const partial = pending; + pending = void 0; + if (partial === void 0 || stopped) + return; + inFlight = (async () => { + const message = copyJson(partial, { omitUndefinedProperties: true }); + const taken = changes.take(message); + try { + await runtime.commit(async (tx) => { + const live = await tx.doc(LiveDoc, runtime.conversationId); + live.generation ??= { attempt }; + changes.commit(live.generation, message, taken); + return void 0; + }, context, PROGRESS_COMMIT); + } catch (error) { + changes.restore(taken); + throw error; + } + })().catch((error) => { + if (!runtime.signal.aborted) + runtime.report(error); + }).finally(() => { + inFlight = void 0; + if (pending !== void 0 && !stopped) + timer = runtime.setTimer(flush, interval); + }); + }; + try { + const events = runtime.models.streamSimple(model2, { messages: [...messages] }, options); + for await (const event of events) { + if (event.type === "done" || event.type === "error" || event.partial.content.length === 0) + continue; + changes.observe(event); + pending = event.partial; + if (timer === void 0 && inFlight === void 0) + timer = runtime.setTimer(flush, interval); + } + return await events.result(); + } finally { + stopped = true; + timer?.(); + await inFlight; + } +} +async function classify(runtime, request2, message, context) { + runtime.signal.throwIfAborted(); + const conversationId = runtime.conversationId; + const { attempt, compacted, model: ref, thinkingLevel, cutoff } = request2; + if (thinkingLevel !== void 0) + message.thinkingLevel = thinkingLevel; + if (message.stopReason === "deferred" && message.deferred !== void 0) { + const handle = message.deferred; + const pollAt = Math.max(runtime.now() + (handle.pollAfterMs ?? DEFAULT_POLL_AFTER_MS), request2.pollAt === void 0 ? Number.NEGATIVE_INFINITY : request2.pollAt + 1); + await runtime.commit(async (tx) => { + (await tx.doc(LiveDoc, conversationId)).generation = { attempt, deferred: { pollAt } }; + const checkpoint = { + phase: "poll", + attempt, + ...compacted === void 0 ? {} : { compacted }, + model: ref, + ...thinkingLevel === void 0 ? {} : { thinkingLevel }, + cutoff, + handle, + pollAt + }; + return { status: "sleeping", checkpoint, until: pollAt }; + }, context); + return; + } + await runtime.hooks.each("afterResponse", (hook2) => hook2(message, runtime, context)); + const calls = message.content.filter((content) => content.type === "toolCall"); + if (message.stopReason === "toolUse" && calls.length > 0) { + return startToolRound(runtime, request2, message, calls, context); + } + if (message.stopReason === "stop" || message.stopReason === "length" || message.stopReason === "toolUse") { + return answer(runtime, message, context); + } + const settings = runtime.settings; + const overflow = message.stopReason === "error" && isContextOverflow(message); + const compaction = compactionPolicy(settings, ref); + if (overflow && compacted === void 0 && compaction.enabled) { + const view2 = await runtime.context(conversationId, context, { at: cutoff }); + if (selectCut(view2, compaction.keepRecentTokens) !== void 0) { + const text = message.errorMessage ?? "Context overflow"; + await runtime.commit(async (tx) => { + const live = await tx.doc(LiveDoc, conversationId); + await appendAssistant(tx, conversationId, message); + delete live.generation; + const child = await createCompaction(tx, conversationId, { reason: "overflow" }, runtime.taskId); + const checkpoint = { phase: "prepare", attempt, compacted: child, overflow: text }; + return { status: "waiting", checkpoint, on: [child], policy: "allSettled" }; + }, context); + return; + } + } + const policy = settings.retry; + const retry = message.stopReason === "error" && !overflow && isRetryableAssistantError(message) && policy.enabled && attempt <= policy.maxRetries; + const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0; + await runtime.commit(async (tx) => { + const failure = retry ? void 0 : await prepareFailure(tx, runtime); + const live = await tx.doc(LiveDoc, conversationId); + await appendAssistant(tx, conversationId, message); + if (failure === void 0) { + live.generation = { attempt, retry: { at: until, error: message.errorMessage ?? "" } }; + const checkpoint = { + phase: "retry", + attempt, + ...compacted === void 0 ? {} : { compacted }, + until + }; + return { status: "sleeping", checkpoint, until }; + } + const text = message.errorMessage ?? `Model response ended with stop reason ${message.stopReason}`; + await endFailedRun(tx, runtime, live, failure, { status: "unanswered", reason: "model_error", detail: text }); + return { + status: "terminal", + outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } } + }; + }, context); +} +async function answer(runtime, message, context) { + let continuation; + await runtime.hooks.each("onYield", async (hook2) => { + if (continuation !== void 0) + return; + continuation = (await hook2(message, runtime, context))?.continue; + }); + const afterRun = await asksAfterRun(runtime, context); + const conversationId = runtime.conversationId; + await runtime.commit(async (tx) => { + const boundary = await prepareBoundary(tx, conversationId, runtime.settings); + const live = await tx.doc(LiveDoc, conversationId); + const entry2 = await appendAssistant(tx, conversationId, message); + const result = { status: "terminal", outcome: { status: "completed", result: { entryId: entry2.id } } }; + const continues = takesContinuation(continuation, boundary) ? continuation : void 0; + if (continues === void 0 && afterRun) { + const held = holdRun(tx, live, runtime, { reason: "answer", answer: entry2.id, last: entry2.id }); + if (held !== void 0) + return held; + } + const { users, first } = await applyBoundary(tx, boundary, "final", runtime.now()); + if (continues !== void 0) { + const user = { role: "user", content: continues, timestamp: runtime.now() }; + await tx.appendEntry(UserEntry, conversationId, { model: [user] }); + if (live.run?.taskId === runtime.taskId) + live.run.turns = 0; + handOver(live, runtime.taskId, await createGeneration(tx, conversationId)); + delete live.generation; + return result; + } + endRun(tx, live, runtime.taskId, { status: "done", answer: entry2.id }); + if (users.length > 0) + await startRun(tx, conversationId, live, { inputs: users, first }); + return result; + }, context); +} +async function asksAfterRun(runtime, context) { + const hooks = agentHooks(await runtime.agent(context), GenerationTask.definition.name); + return hooks.some(({ handlers }) => typeof handlers.afterRun === "function"); +} +function holdRun(tx, live, runtime, end) { + const run2 = live.run; + if (run2?.taskId !== runtime.taskId) + return void 0; + for (const id of run2.inputs) + tx.settleSubmission(id, { status: "done", answer: end.answer }); + run2.inputs = []; + run2.afterRun = {}; + delete live.generation; + delete live.tools; + delete live.nestedTools; + const summary = { ...end, first: run2.first ?? end.answer }; + return { status: "running", checkpoint: { phase: "finish", run: summary } }; +} +function prepareRunEndTasks(runtime, requests, prepared) { + const rejected = []; + for (const request2 of requests) { + try { + const name = request2.task.definition.name; + if (name.startsWith("pi.")) + throw new Error(`An afterRun hook cannot start the built-in task ${name}`); + const installed = runtime.registry.task(name); + if (installed === void 0) + throw new Error(`Task ${name} is not installed`); + const input = copyJson(request2.input, { omitUndefinedProperties: true }); + const definition = installed.definition; + const checkpoint = copyJson(definition.initial(input), { omitUndefinedProperties: true }); + if (!Object.hasOwn(definition.phases, checkpoint.phase)) { + throw new Error(`Task ${name} starts at the unknown phase ${String(checkpoint.phase)}`); + } + prepared.push({ task: { definition: { ...definition, initial: () => checkpoint } }, input }); + } catch (error) { + rejected.push(error); + } + } + if (rejected.length === 1) + throw rejected[0]; + if (rejected.length > 1) + throw new AggregateError(rejected, `${rejected.length} afterRun task requests were rejected`); +} +async function endAnsweredRun(runtime, answer2, context) { + const conversationId = runtime.conversationId; + await runtime.commit(async (tx) => { + const boundary = await prepareBoundary(tx, conversationId, runtime.settings); + const live = await tx.doc(LiveDoc, conversationId); + const { users, first } = await applyBoundary(tx, boundary, "final", runtime.now()); + endRun(tx, live, runtime.taskId, { status: "done", answer: answer2 }); + if (users.length > 0) + await startRun(tx, conversationId, live, { inputs: users, first }); + return { status: "terminal", outcome: { status: "completed", result: { entryId: answer2 } } }; + }, context); +} +async function endAnsweredCutOff(runtime, task, answer2, context) { + const text = `The generation was interrupted ${cutOffCount(task)} in its ${task.state.checkpoint.phase} phase`; + await runtime.commit(async (tx) => { + const live = await tx.doc(LiveDoc, runtime.conversationId); + endRun(tx, live, runtime.taskId, { status: "done", answer: answer2 }); + const completed = { status: "terminal", outcome: { status: "completed", result: { entryId: answer2 } } }; + return endedByCrashes(completed, text); + }, context); +} +function takesContinuation(continuation, boundary) { + const items = boundary.inbox.items; + return continuation !== void 0 && !items.some((item) => item.mode === "steer" || item.mode === "followUp") && !hasQueuedReset(items); +} +async function startToolRound(runtime, request2, message, calls, context) { + const conversationId = runtime.conversationId; + const messages = request2.messages ?? (await runtime.context(conversationId, context, { at: request2.cutoff })).messages; + const offered = new Set(getCurrentTools(messages).map((tool) => tool.name)); + const tools = (await runtime.agent(context)).tools; + const sequential = runtime.settings.toolExecution === "sequential" || calls.some((call) => offered.has(call.name) && tools.find((tool) => tool.name === call.name)?.executionMode === "sequential"); + await runtime.commit(async (tx) => { + const live = await tx.doc(LiveDoc, conversationId); + const entry2 = await appendAssistant(tx, conversationId, message); + const slots = []; + const tools2 = []; + const pending = []; + for (const call of calls) { + const unoffered = offered.has(call.name) ? {} : { unoffered: true }; + if (sequential && tools2.length > 0) { + pending.push(call.id); + slots.push({ callId: call.id, name: call.name, status: "pending", ...unoffered }); + continue; + } + const taskId = await createToolTask(tx, runtime, entry2.id, call.id, unoffered.unoffered); + tools2.push(taskId); + slots.push({ callId: call.id, name: call.name, taskId, status: "pending", ...unoffered }); + } + delete live.generation; + live.tools = slots; + if (live.run?.taskId === runtime.taskId) { + live.run.turns += 1; + live.run.first ??= entry2.id; + } + const checkpoint = { phase: "tools", assistant: entry2.id, tools: tools2, pending }; + return { status: "waiting", checkpoint, on: tools2, policy: "allSettled" }; + }, context); +} +async function finishToolRound(runtime, assistant, tools, context) { + const conversationId = runtime.conversationId; + const controls = /* @__PURE__ */ new Map(); + const outcomes = await runtime.outcomes(tools, context); + tools.forEach((id, index) => { + const outcome = outcomes[index]; + controls.set(id, outcome.status === "completed" && outcome.result.kind !== "nested" ? outcome.result.control : void 0); + }); + const slots = (await runtime.snapshot(LiveDoc, conversationId, context))?.tools ?? []; + const results = slots.flatMap((slot) => slot.entry === void 0 ? [] : [slot.entry]); + await runtime.hooks.each("afterTools", (hook2) => hook2(assistant, results, runtime, context)); + const ordered = slots.map((slot) => slot.taskId === void 0 ? void 0 : controls.get(slot.taskId)); + const terminate = slots.length > 0 && ordered.every((control) => control?.terminate === true); + const added = ordered.flatMap((control) => control?.addTools ?? []); + const handoff = ordered.findLast((control) => control?.handoff !== void 0)?.handoff; + const maxTurns = (await runtime.agent(context)).maxTurns; + const afterRun = terminate && await asksAfterRun(runtime, context); + await runtime.commit(async (tx) => { + const boundary = await prepareBoundary(tx, conversationId, runtime.settings); + if (added.length > 0) + await addTools(tx, conversationId, added); + const live = await tx.doc(LiveDoc, conversationId); + const now = runtime.now(); + let last = results.reduce((newest, id) => id > newest ? id : newest, assistant); + if (handoff !== void 0) { + const message = { role: "user", content: handoff, timestamp: now }; + const entry2 = await tx.appendEntry(ResetEntry, conversationId, { head: "self", model: [message] }); + boundary.head = entry2.id; + last = entry2.id; + } + const turns = live.run?.taskId === runtime.taskId ? live.run.turns : 0; + const held = afterRun ? holdRun(tx, live, runtime, { reason: "terminate", answer: assistant, last }) : void 0; + if (held !== void 0) + return held; + if (terminate) { + const { users, first } = await applyBoundary(tx, boundary, "final", now); + endRun(tx, live, runtime.taskId, { status: "done", answer: assistant }); + if (users.length > 0) + await startRun(tx, conversationId, live, { inputs: users, first }); + } else if (maxTurns !== void 0 && turns >= maxTurns) { + const policy = runtime.settings.afterFailedRun; + const failure = { policy, ...policy === "continue" ? { boundary } : {} }; + await endFailedRun(tx, runtime, live, failure, { + status: "unanswered", + reason: "turn_limit", + detail: turns === 1 ? "1 turn" : `${turns} turns` + }); + } else { + const { users, first, reset } = await applyBoundary(tx, boundary, "postTools", now); + if (reset) { + endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "reset" }); + if (users.length > 0) { + await startRun(tx, conversationId, live, { inputs: users, first }, { keepFailure: true }); + } + } else { + delete live.tools; + delete live.nestedTools; + if (live.run?.taskId === runtime.taskId) + live.run.inputs.push(...users); + handOver(live, runtime.taskId, await createGeneration(tx, conversationId)); + } + } + return { status: "terminal", outcome: { status: "completed", result: { entryId: assistant } } }; + }, context); +} +async function appendAssistant(tx, conversationId, message) { + await recordUsage(tx, conversationId, "models", `${message.provider}/${message.model}`, message.usage); + return tx.appendEntry(AssistantEntry, conversationId, { model: [message] }); +} +async function startRun(tx, conversationId, live, run2, options) { + const { inputs, first } = run2; + const taskId = await createGeneration(tx, conversationId, { submissionIds: inputs }); + live.run = { taskId, inputs, turns: 0, ...first === void 0 ? {} : { first } }; + if (options?.keepFailure !== true) + delete live.runFailure; +} +function createGeneration(tx, conversationId, telemetry = {}) { + return tx.createTask(GenerationTask, {}, { ownership: { kind: "conversation" }, conversationId, ...telemetry }); +} +function handOver(live, from, to) { + if (live.run?.taskId === from) + live.run.taskId = to; +} + +// node_modules/@earendil-works/pi-durable/dist/harness/live.js +var LiveDoc = defineDoc({ + kind: "pi.live", + version: 3, + scope: "conversation", + history: "latest", + fork: "initial", + initial: () => ({}), + // Version 1 named a compaction's trigger `reason`, in `compactions` and `compactionFailure`. Versions 1 and 2 (as + // builds before `run.turns` stored it) may lack `run.turns`: a run in flight across the upgrade counts its turns + // from 0. Version 2 already names the trigger `trigger`, so renaming leaves it alone. + migrate: (value2) => { + const live = value2; + const { run: run2, compactions, compactionFailure: compactionFailure2 } = live; + return { + ...value2, + ...run2 === void 0 ? {} : { run: { ...run2, turns: typeof run2.turns === "number" ? run2.turns : 0 } }, + ...compactions === void 0 ? {} : { compactions: compactions.map(renameReason) }, + ...compactionFailure2 === void 0 ? {} : { compactionFailure: renameReason(compactionFailure2) } + }; + }, + // REMINDER: a complete base whenever nothing runs (spec §8.2): no generation and no running tool slot. That holds + // while idle, in the commit handing a generation over to its tool round, and between tools, so the delta chain + // spans at most one generation or the overlapping execution of one round's tools. A slot holds output only while + // running, so every base is small; a nested call's result lives in its caller's task documents, never here. Do not + // add a delta-count bound; the tool output benchmark checks this rule. + checkpointWhen: (value2) => value2.generation === void 0 && !(value2.tools ?? []).some((slot) => slot.status === "running") && !(value2.nestedTools ?? []).some((slot) => slot.status === "running") +}); +function renameReason(value2) { + return Object.fromEntries(Object.entries(value2).map(([key, field]) => [key === "reason" ? "trigger" : key, field])); +} +var PROGRESS_COMMIT = { persistence: { kind: "deferred" } }; +var RUN_TASK_KINDS = /* @__PURE__ */ new Set(["pi.generation"]); +var TOOL_TASK_KIND = "pi.tool"; +var COMPACTION_TASK_KIND = "pi.compaction"; +var LIMITS_RESTARTS = /* @__PURE__ */ new Set([...RUN_TASK_KINDS, TOOL_TASK_KIND, COMPACTION_TASK_KIND]); +function cutOffTooOften(task, runtime) { + return task.restarts !== void 0 && task.restarts >= runtime.settings.restartLimit; +} +function salvageable(task, runtime) { + return (task.restarts ?? 0) <= runtime.settings.restartLimit; +} +function cutOffCount(task) { + const restarts = task.restarts ?? 0; + return restarts === 1 ? "once" : `${restarts} times in a row`; +} +function endRun(tx, live, taskId, end) { + if (live.run?.taskId === taskId) { + const inputs = [...live.run.inputs]; + const { first } = live.run; + if (end.status === "done") { + for (const id of inputs) + tx.settleSubmission(id, { status: "done", answer: end.answer }); + } else if (live.run.afterRun === void 0) { + const { reason, detail } = end; + const settlement = end.settlement ?? { + status: "unanswered", + reason, + ...detail === void 0 ? {} : { detail } + }; + for (const id of inputs) + tx.settleSubmission(id, settlement); + live.runFailure = { + taskId, + inputs, + reason, + ...detail === void 0 ? {} : { detail }, + ...first === void 0 ? {} : { first } + }; + } + delete live.run; + } + delete live.generation; + delete live.tools; + delete live.nestedTools; +} +function addCompactionStatus(live, status) { + live.compactions ??= []; + live.compactions.push(status); +} +function compactionStatus(live, taskId) { + return live.compactions?.find((status) => status.taskId === taskId); +} +function removeCompactionStatus(live, taskId) { + const statuses = live.compactions; + if (statuses === void 0) + return; + const index = statuses.findIndex((status) => status.taskId === taskId); + if (index >= 0) + statuses.splice(index, 1); + if (statuses.length === 0) + delete live.compactions; +} +function failCompactionStatus(live, taskId, error) { + const status = compactionStatus(live, taskId); + if (status !== void 0) + live.compactionFailure = { taskId: status.taskId, trigger: status.trigger, error }; + removeCompactionStatus(live, taskId); +} +function toolSlot(live, taskId) { + const slot = live.tools?.find((each) => each.taskId === taskId); + if (slot !== void 0 || live.nestedTools === void 0) + return slot; + const nested = live.nestedTools; + let low = 0; + let high = nested.length - 1; + while (low <= high) { + const middle = low + high >>> 1; + const candidate = nested[middle]; + if (candidate.taskId === taskId) + return candidate; + if (candidate.taskId < taskId) + low = middle + 1; + else + high = middle - 1; + } + return void 0; +} +function finishSlot(slot) { + slot.status = "done"; + clearProgress(slot); +} +function removeNestedSlots(live, taskId) { + const nested = live.nestedTools; + if (nested === void 0) + return; + const removed = /* @__PURE__ */ new Set([taskId]); + for (const slot of nested) + if (removed.has(slot.parentTaskId)) + removed.add(slot.taskId); + for (let index = nested.length - 1; index >= 0; index--) { + if (removed.has(nested[index].parentTaskId)) + nested.splice(index, 1); + } + if (nested.length === 0) + delete live.nestedTools; +} +function clearProgress(slot) { + delete slot.output; + delete slot.droppedBytes; + delete slot.droppedLines; + delete slot.details; + delete slot.diagnostics; +} +async function settleSchedulerOutcome(tx, record, outcome) { + if (record.kind === TOOL_TASK_KIND) { + const live2 = await tx.doc(LiveDoc, record.conversationId); + const slot = toolSlot(live2, record.id); + if (slot !== void 0) + finishSlot(slot); + removeNestedSlots(live2, record.id); + return; + } + const detail = outcome.status === "faulted" ? outcome.error.detail : void 0; + const cutOff = typeof detail === "object" && detail !== null && "reason" in detail && detail.reason === "restart_limit"; + if (record.kind === COMPACTION_TASK_KIND) { + const live2 = await tx.doc(LiveDoc, record.conversationId); + if (cutOff) + removeCompactionStatus(live2, record.id); + else + failCompactionStatus(live2, record.id, outcome.status === "faulted" ? outcome.error.message : outcome.reason); + return; + } + if (!RUN_TASK_KINDS.has(record.kind)) + return; + const live = await tx.doc(LiveDoc, record.conversationId); + if (live.run?.taskId !== record.id) + return; + if (!cutOff) + await convertPartial(tx, live, record.conversationId); + endRun(tx, live, record.id, outcome.status === "faulted" ? { status: "unanswered", reason: cutOff ? "aborted" : "faulted", detail: outcome.error.message } : { + status: "unanswered", + reason: "orphaned", + detail: outcome.reason, + settlement: { status: "unanswered", reason: outcome.reason } + }); +} + +// node_modules/@earendil-works/pi-durable/dist/harness/submissions.js +var Submissions = class { + #telemetry; + #session; + #now; + /** Read at each admission, on the Session line. */ + #queueModes; + /** Enable task scheduling; submitting or waiting asks for progress. */ + #resume; + #waiters = new Waiters(); + #closed = false; + constructor(session, now, queueModes, resume, telemetry = NOOP_TELEMETRY_CONTEXT) { + this.#telemetry = telemetry; + this.#session = session; + this.#now = now; + this.#queueModes = queueModes; + this.#resume = resume; + session.followCommits((publication) => this.#observe(publication)); + session.subscribeClose(() => { + this.#closed = true; + this.#waiters.rejectAll(closedError(this.#session)); + }); + } + /** Admit a submission in one commit; see `admitSubmission()`. */ + async submit(conversationId, draft, context) { + const { id } = await this.#traced(conversationId, draft, context, (span) => this.#session.commitWith((tx) => admitSubmission(tx, conversationId, draft, this.#now(), this.#queueModes(), span), withTelemetry(span, context))); + return new SubmissionHandle(id, this); + } + /** + * Admit a submission inside the caller's commit `tx`, with the rules of `submit()`. The admission is stored or lost + * with that commit. It must come before the commit's first table write, another admission's included. + */ + async admit(tx, conversationId, draft) { + if (!this.#session.ownsTransaction(tx)) + throw new Error("The transaction belongs to another Session"); + if (tx.hasTableWrite) + throw new ReadAfterWrite("submit(tx, draft)"); + const admission = await this.#traced(conversationId, draft, tx.context, (span) => admitSubmission(tx, conversationId, draft, this.#now(), this.#queueModes(), span)); + return admission.id; + } + /** Run one admission in its `pi.durable.submit` span, asking for progress first, as the run it starts needs it. */ + #traced(conversationId, draft, context, admit) { + this.#resume(); + const startsRun = draft.type !== "write"; + const parent = startsRun ? this.#telemetry : context.telemetry; + return createTypedSpanStarter(parent, [DURABLE_TELEMETRY_SCHEMA])("pi.durable.submit", { + "submission.type": draft.type, + "conversation.id": conversationId + }, async (span) => { + if (startsRun) + span.addLink({ context: context.telemetry }); + try { + const admission = await admit(span); + span.setAttributes({ "submission.id": admission.id, result: admission.status }); + return admission; + } catch (error) { + span.setStatus({ status: "error" }); + throw error; + } + }); + } + /** Handle for an existing submission, or `undefined`. */ + async get(id, context) { + const record = await this.#session.readOnLine((storage) => storage.submission(id, context)); + return record === void 0 ? void 0 : new SubmissionHandle(record.id, this); + } + async status(id, context) { + const record = await this.#session.readOnLine((storage) => storage.submission(id, context)); + if (record === void 0) + throw new Error(`Submission ${id} does not exist`); + return record; + } + async wait(id, context) { + this.#resume(); + const found = await this.#session.readOnLine(async (storage) => { + const record = await storage.submission(id, context); + if (record === void 0) + throw new Error(`Submission ${id} does not exist`); + if (isSettled(record)) + return { promise: Promise.resolve(record) }; + if (this.#closed) + throw closedError(this.#session); + return { promise: this.#waiters.add(id, context) }; + }); + return found.promise; + } + /** Withdraw a queued submission and remove its inbox item; placed inputs and settled submissions are reported. */ + abort(id, context, conversationId) { + return this.#session.commitWith(async (tx) => { + const record = await tx.submission(id); + if (record === void 0 || conversationId !== void 0 && record.conversationId !== conversationId) { + return "not_found"; + } + if (record.status === "queued") { + tx.settleSubmission(id, { status: "unanswered", reason: "aborted" }); + await removeInboxItem(tx, record.conversationId, id); + return "aborted"; + } + return record.status === "placed" ? "already_placed" : "settled"; + }, context); + } + #observe(publication) { + if (publication.kind === "memory") + return; + for (const change of publication.changes) { + if (change.type !== "submission" || !isSettled(change.value)) + continue; + this.#waiters.resolve(change.value.id, change.value); + } + } +}; +var SubmissionHandle = class { + id; + #submissions; + constructor(id, submissions) { + this.id = id; + this.#submissions = submissions; + } + status(context) { + return this.#submissions.status(this.id, context); + } + wait(context) { + return this.#submissions.wait(this.id, context); + } + async abort(context) { + const result = await this.#submissions.abort(this.id, context); + if (result === "not_found") + throw new Error(`Submission ${this.id} does not exist`); + return result; + } +}; +function isSettled(record) { + return record.status === "done" || record.status === "unanswered"; +} +function checkInputEntry(entry2) { + const { kind } = entry2; + if (typeof kind !== "string" || kind.length === 0 || kind.startsWith("pi.")) { + throw new TypeError(`An input entry's kind must be a non-empty string not starting with "pi.", got ${String(kind)}`); + } +} +async function admitSubmission(tx, conversationId, draft, now, queueModes, span) { + if (draft.type === "input" && draft.entry !== void 0) + checkInputEntry(draft.entry); + if (draft.requestId !== void 0) { + const existing = await tx.submissionByRequest(conversationId, draft.requestId); + if (existing !== void 0) { + if (existing.type !== draft.type) { + throw new Error(`Request ${draft.requestId} already identifies a submission of type ${existing.type}`); + } + return { id: existing.id, status: existing.status === "unanswered" ? "done" : existing.status }; + } + } + const fields = { conversationId, requestId: draft.requestId, trace: span?.propagation?.inject(span) }; + if (draft.type === "write" && isDisplayOnly(draft.entry) && !hasQueuedReset((await tx.doc(InboxDoc, conversationId)).items)) { + const entry3 = await tx.appendEntry(conversationId, draft.entry); + const write = { ...fields, type: "write", status: "done", entry: entry3.id }; + return { id: (await tx.createSubmission(write)).id, status: "done" }; + } + const live = await tx.doc(LiveDoc, conversationId); + const busy = live.run !== void 0; + if (draft.type === "input" && draft.whenBusy === "steer" && live.run !== void 0 && span?.propagation !== void 0) { + const run2 = await tx.task(live.run.taskId); + if (run2?.trace !== void 0) + span.addLink({ context: span.propagation.extract(run2.trace) }); + } + if (busy && draft.type === "input" && draft.whenBusy === "reject") + throw new ConversationBusy(conversationId); + if (!busy && draft.type === "input" && draft.whenIdle === "reject") + throw new ConversationIdle(conversationId); + if (draft.type === "continue") { + if (busy) + throw new ConversationBusy(conversationId); + const boundary2 = await prepareBoundary(tx, conversationId, queueModes); + const { id: id2 } = await tx.createSubmission({ ...fields, type: "continue", status: "placed" }); + const { users, first } = await applyBoundary(tx, boundary2, "final", now); + await startRun(tx, conversationId, live, { inputs: [...users, id2], first: live.runFailure?.first ?? first }); + return { id: id2, status: "placed" }; + } + const boundary = busy ? void 0 : await prepareBoundary(tx, conversationId, queueModes); + if (boundary === void 0 || boundary.inbox.items.length > 0) { + const { id: id2 } = await tx.createSubmission({ + ...fields, + type: draft.type, + status: "queued" + }); + const value2 = copyJson(draft.type === "write" ? draft.entry : draft.content, { + omitUndefinedProperties: true + }); + const items = (boundary?.inbox ?? await tx.doc(InboxDoc, conversationId)).items; + if (draft.type === "write") + items.push({ id: id2, mode: "write", entry: value2 }); + else { + const mode = draft.whenBusy === "steer" ? "steer" : "followUp"; + const content = value2; + const entry3 = draft.entry === void 0 ? {} : { entry: pickInputEntry(draft.entry) }; + items.push({ id: id2, mode, content, ...entry3 }); + } + if (boundary === void 0) + return { id: id2, status: "queued" }; + const { users, first } = await applyBoundary(tx, boundary, "final", now); + if (users.length > 0) + await startRun(tx, conversationId, live, { inputs: users, first }); + return { id: id2, status: draft.type === "write" ? "done" : users.at(-1) === id2 ? "placed" : "queued" }; + } + if (draft.type === "write") { + if (isStale(boundary, draft.entry)) { + const stale = { status: "unanswered", reason: "stale" }; + return { + id: (await tx.createSubmission({ ...fields, type: "write", ...stale })).id, + status: "done" + }; + } + const entry3 = await tx.appendEntry(conversationId, draft.entry); + const write = { ...fields, type: "write", status: "done", entry: entry3.id }; + return { id: (await tx.createSubmission(write)).id, status: "done" }; + } + const entry2 = await appendInput(tx, conversationId, draft.content, draft.entry, now); + const input = { ...fields, type: "input", status: "placed", entry: entry2.id }; + const { id } = await tx.createSubmission(input); + await startRun(tx, conversationId, live, { inputs: [id], first: entry2.id }); + return { id, status: "placed" }; +} + +// node_modules/@earendil-works/pi-durable/dist/harness/compaction.js +var TOOL_RESULT_MAX_CHARS = 2e3; +var SUMMARY_PREFIX = "The conversation history before this point was compacted into the following summary:\n\n\n"; +var SUMMARY_SUFFIX = "\n"; +var SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. + +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; +var SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. If the conversation starts with an earlier summary, preserve its information and fold the newer messages into it. + +Use this EXACT format: + +## Goal +[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned by user] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] +- [Or "(none)" if not applicable] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; +var CompactionTask = defineTask({ + name: "pi.compaction", + version: 1, + initial: () => ({ phase: "select" }), + phases: { + select: async (task, runtime, context) => { + if (cutOffTooOften(task, runtime)) + return failCutOff(runtime, task, context); + const { conversationId } = runtime; + const agent = await runtime.agent(context); + const settings = runtime.settings; + const ref = agent.model; + const model2 = ref === void 0 ? void 0 : runtime.models.getModel(ref.provider, ref.modelId); + if (ref === void 0 || model2 === void 0) + return failNoModel2(runtime, ref, context); + const policy = compactionPolicy(settings, ref); + const view2 = await runtime.context(conversationId, context); + const cut = selectCut(view2, policy.keepRecentTokens); + if (cut === void 0) + return complete(runtime, context); + const firstKept = view2.entries[cut].id; + const { reason, instructions } = task.input; + const compaction = { + reason, + entries: view2.entries.slice(0, cut), + messages: summarizedMessages(view2, cut), + firstKept, + ...instructions === void 0 ? {} : { instructions } + }; + let decision; + await runtime.hooks.each("beforeCompact", async (hook2) => { + if (decision === void 0) + decision = await hook2(compaction, runtime, context); + }); + if (decision !== void 0 && "decline" in decision) + return complete(runtime, context); + if (decision !== void 0) + return place(runtime, firstKept, decision.summary, context); + const request2 = { + attempt: 1, + model: ref, + thinkingLevel: agent.thinkingLevel, + streamOptions: settings.stream, + maxTokens: Math.min(Math.floor(0.8 * policy.reserveTokens), model2.maxTokens > 0 ? model2.maxTokens : Number.POSITIVE_INFINITY), + tail: view2.entries.reduce((tail, entry2) => entry2.id > tail ? entry2.id : tail, firstKept), + firstKept + }; + await runtime.commit(() => ({ status: "running", checkpoint: { phase: "summarize", ...request2 } }), context); + }, + summarize: async (task, runtime, context) => { + if (cutOffTooOften(task, runtime)) + return failCutOff(runtime, task, context); + const { phase: _, ...request2 } = task.state.checkpoint; + const { model: ref, thinkingLevel, streamOptions, maxTokens, tail, firstKept, attempt } = request2; + const model2 = runtime.models.getModel(ref.provider, ref.modelId); + if (model2 === void 0) + return failNoModel2(runtime, ref, context); + const view2 = await runtime.context(runtime.conversationId, context, { at: tail }); + const cut = view2.entries.findIndex((entry2) => entry2.id === firstKept); + const now = runtime.now(); + const messages = [ + { role: "system", content: SUMMARIZATION_SYSTEM_PROMPT, timestamp: now }, + { + role: "user", + content: [{ type: "text", text: summaryPrompt(summarizedMessages(view2, cut), task.input.instructions) }], + timestamp: now + } + ]; + const { deferred: _deferred, ...forwarded } = streamOptions; + const options = { + ...forwarded, + cacheRetention: "none", + maxTokens, + signal: runtime.signal, + telemetry: context.telemetry, + sessionId: await ensureProviderSessionId(runtime, context), + ...thinkingLevel === "off" ? {} : { reasoning: thinkingLevel } + }; + const message = await runtime.models.completeSimple(model2, { messages }, options); + runtime.signal.throwIfAborted(); + const summary = summaryText(message); + const policy = runtime.settings.retry; + const retry = message.stopReason === "error" && isRetryableAssistantError(message) && policy.enabled && attempt <= policy.maxRetries; + const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0; + await runtime.commit(async (tx, current) => { + await recordUsage(tx, runtime.conversationId, "models", `${message.provider}/${message.model}`, message.usage); + const live = await tx.doc(LiveDoc, runtime.conversationId); + if (summary !== void 0) + return placeSummary(tx, runtime, current, live, firstKept, summary); + if (retry) { + const status = compactionStatus(live, runtime.taskId); + if (status !== void 0) + status.retry = { at: until, error: message.errorMessage ?? "" }; + const checkpoint = { phase: "retry", ...request2, until }; + return { status: "sleeping", checkpoint, until }; + } + const text = summaryFailure(message); + failCompactionStatus(live, runtime.taskId, text); + return { + status: "terminal", + outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } } + }; + }, context); + }, + /** After the backoff's sleep; a run before `until` (an early wake, or a record of an earlier version) sleeps again. */ + retry: async (task, runtime, context) => { + const { phase: _, until, ...request2 } = task.state.checkpoint; + if (cutOffTooOften(task, runtime)) + return failCutOff(runtime, task, context); + if (until > runtime.now()) + return runtime.sleepUntil(until, task.state.checkpoint, context); + const attempt = request2.attempt + 1; + await runtime.commit(async (tx) => { + const status = compactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); + if (status !== void 0) { + status.attempt = attempt; + delete status.retry; + } + return { status: "running", checkpoint: { phase: "summarize", ...request2, attempt } }; + }, context); + } + }, + abort: async (_task, runtime, context) => { + await runtime.commit(async (tx) => { + removeCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); + return { status: "terminal", outcome: { status: "aborted" } }; + }, context); + } +}); +async function createCompaction(tx, conversationId, input, owner) { + const ownership = owner === void 0 ? { kind: "conversation" } : { kind: "task", taskId: owner }; + const background = owner === void 0 && input.reason !== "manual"; + const taskId = await tx.createTask(CompactionTask, input, { ownership, conversationId, background }); + const status = { taskId, trigger: input.reason, blocking: owner !== void 0, attempt: 1 }; + addCompactionStatus(await tx.doc(LiveDoc, conversationId), status); + return taskId; +} +function compactionPolicy(settings, model2) { + const { modelOverrides, ...policy } = settings.compaction; + const override = modelOverrides?.[`${model2.provider}/${model2.modelId}`]; + return { + enabled: override?.enabled ?? policy.enabled, + reserveTokens: override?.reserveTokens ?? policy.reserveTokens, + keepRecentTokens: override?.keepRecentTokens ?? policy.keepRecentTokens, + backgroundTokens: override?.backgroundTokens ?? policy.backgroundTokens + }; +} +function selectCut(view2, keepRecentTokens) { + const { contributions } = view2; + const start = view2.head === void 0 ? 0 : 1; + const candidates = []; + for (let index = start; index < contributions.length; index++) { + if (isCandidate(contributions, index)) + candidates.push(index); + } + let kept = 0; + let cut; + for (let index = contributions.length - 1; index >= start; index--) { + for (const message of contributions[index]) + kept += estimateMessageTokens(message); + if (kept < keepRecentTokens) + continue; + cut = candidates.find((candidate) => candidate >= index) ?? candidates.at(-1); + break; + } + if (cut === void 0) + return void 0; + for (let index = start; index < cut; index++) + if (contributions[index].length > 0) + return cut; + return void 0; +} +function isCandidate(contributions, index) { + const first = contributions[index][0]; + if (first?.role === "assistant") + return true; + if (first?.role !== "user") + return false; + let calls = /* @__PURE__ */ new Set(); + for (let before = index - 1; before >= 0; before--) { + const assistant = contributions[before].findLast((message) => message.role === "assistant"); + if (assistant === void 0) + continue; + calls = new Set(assistant.content.flatMap((content) => content.type === "toolCall" ? [content.id] : [])); + break; + } + if (calls.size === 0) + return true; + for (let after = index; after < contributions.length; after++) { + for (const [position, message] of contributions[after].entries()) { + if (message.role === "assistant" && (after > index || position > 0)) + return true; + if (message.role === "toolResult" && calls.has(message.toolCallId)) + return false; + } + } + return true; +} +function summarizedMessages(view2, cut) { + return orderToolResults(view2.contributions.slice(0, cut).flat()); +} +function estimateContext(view2, extra) { + let measured; + const after = view2.head?.id ?? Number.NEGATIVE_INFINITY; + for (let index = view2.entries.length - 1; index >= 0 && measured === void 0; index--) { + if (view2.entries[index].id <= after) + continue; + measured = view2.contributions[index].findLast((message) => message.role === "assistant" && calculateContextTokens(message.usage) > 0); + } + const from = measured === void 0 ? 0 : view2.messages.lastIndexOf(measured) + 1; + let tokens = measured === void 0 ? 0 : calculateContextTokens(measured.usage); + for (const message of view2.messages.slice(from)) + tokens += estimateMessageTokens(message); + for (const message of extra) + tokens += estimateMessageTokens(message); + return tokens; +} +function summaryText(message) { + if (message.stopReason !== "stop" || message.content.some((content) => content.type === "toolCall")) + return void 0; + const text = message.content.flatMap((content) => content.type === "text" ? [content.text] : []).join("\n").trim(); + return text.length === 0 ? void 0 : text; +} +function summaryFailure(message) { + if (message.stopReason === "error" || message.stopReason === "aborted") { + return `Summarization failed: ${message.errorMessage ?? message.stopReason}`; + } + if (message.stopReason === "length") + return "Summarization hit the token limit; the summary is incomplete"; + if (message.content.some((content) => content.type === "toolCall")) + return "Summarization attempted to call a tool"; + return "Summarization produced no text"; +} +function summaryPrompt(messages, instructions) { + const focus = instructions === void 0 ? "" : ` + +Additional focus: ${instructions}`; + return ` +${serializeConversation(messages)} + + +${SUMMARIZATION_PROMPT}${focus}`; +} +function serializeConversation(messages) { + const parts = []; + for (const message of messages) { + if (message.role === "user") { + const text = contentText2(message.content); + if (text.length > 0) + parts.push(`[User]: ${text}`); + } else if (message.role === "assistant") { + const text = message.content.flatMap((content) => content.type === "text" ? [content.text] : []); + const calls = message.content.flatMap((content) => content.type === "toolCall" ? [ + `${content.name}(${Object.entries(content.arguments).map(([key, value2]) => `${key}=${JSON.stringify(value2)}`).join(", ")})` + ] : []); + if (text.length > 0) + parts.push(`[Assistant]: ${text.join("\n")}`); + if (calls.length > 0) + parts.push(`[Assistant tool calls]: ${calls.join("; ")}`); + } else if (message.role === "toolResult") { + const text = contentText2(message.content); + if (text.length > 0) + parts.push(`[Tool result]: ${truncate(text, TOOL_RESULT_MAX_CHARS)}`); + } + } + return parts.join("\n\n"); +} +function contentText2(content) { + if (typeof content === "string") + return content; + return content.flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : []).join("\n"); +} +function truncate(text, maxChars) { + if (text.length <= maxChars) + return text; + return `${text.slice(0, maxChars)} + +[... ${text.length - maxChars} more characters truncated]`; +} +async function place(runtime, firstKept, summary, context) { + await runtime.commit(async (tx, current) => placeSummary(tx, runtime, current, await tx.doc(LiveDoc, runtime.conversationId), firstKept, summary), context); +} +async function placeSummary(tx, runtime, current, live, firstKept, summary) { + removeCompactionStatus(live, runtime.taskId); + delete live.compactionFailure; + const text = `${SUMMARY_PREFIX}${summary}${SUMMARY_SUFFIX}`; + const entry2 = { + kind: CompactionEntry.kind, + head: firstKept, + model: [{ role: "user", content: [{ type: "text", text }], timestamp: runtime.now() }], + data: { reason: current.input.reason } + }; + const result = current.owner === void 0 ? { + submissionId: (await admitSubmission(tx, runtime.conversationId, { type: "write", requestId: `compaction:${runtime.taskId}`, entry: entry2 }, runtime.now(), runtime.settings)).id + } : { entryId: (await tx.appendEntry(runtime.conversationId, entry2)).id }; + return { status: "terminal", outcome: { status: "completed", result } }; +} +async function complete(runtime, context) { + await runtime.commit(async (tx) => { + removeCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId); + return { status: "terminal", outcome: { status: "completed", result: {} } }; + }, context); +} +async function failCutOff(runtime, task, context) { + const message = `Compaction was interrupted ${cutOffCount(task)}`; + await runtime.commit(async (tx) => { + failCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId, message); + return { + status: "terminal", + outcome: { status: "failed", error: { message, detail: { reason: "restart_limit" } } } + }; + }, context); +} +async function failNoModel2(runtime, ref, context) { + const message = ref === void 0 ? "No model is configured" : `Model ${ref.provider}/${ref.modelId} is not available`; + await runtime.commit(async (tx) => { + failCompactionStatus(await tx.doc(LiveDoc, runtime.conversationId), runtime.taskId, message); + return { status: "terminal", outcome: { status: "failed", error: { message, detail: { reason: "no_model" } } } }; + }, context); +} + +// node_modules/@earendil-works/pi-durable/dist/harness/define.js +function defineExtension(extension) { + return extension; +} + +// node_modules/@earendil-works/pi-durable/dist/harness/registry.js +var SECTION_KEY = /^[a-z][a-z0-9_-]*$/; +var BUILTIN_TASKS = [GenerationTask, ToolTask, CompactionTask]; +var RegistryState = class { + #extensions; + #byName; + #tasks; + constructor(extensions) { + this.#extensions = extensions; + this.#byName = new Map(extensions.map((extension) => [extension.name, extension])); + const tasks = /* @__PURE__ */ new Map(); + for (const task of BUILTIN_TASKS) + tasks.set(task.definition.name, task); + for (const extension of extensions) { + for (const task of extension.tasks ?? []) { + const name = task.definition.name; + if (tasks.has(name)) + throw new Error(`Task ${name} of extension ${extension.name} is already installed`); + tasks.set(name, task); + } + } + this.#tasks = tasks; + } + installed() { + return this.#extensions; + } + extension(name) { + return this.#byName.get(name); + } + tools() { + return this.#extensions.flatMap((extension) => (extension.tools ?? []).map((tool) => ({ extension, tool }))); + } + sections() { + return this.#extensions.flatMap((extension) => (extension.sections ?? []).map((section2) => ({ extension, section: section2 }))); + } + tasks() { + return [...this.#tasks.values()]; + } + task(name) { + return this.#tasks.get(name); + } +}; +var RegistryImpl = class { + #current = new RegistryState([]); + #listeners = /* @__PURE__ */ new Set(); + snapshot() { + return this.#current; + } + subscribe(listener) { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + install(extension) { + validateExtension(extension); + const current = this.#current.installed(); + const index = current.findIndex((installed) => installed.name === extension.name); + const next = index < 0 ? [...current, extension] : current.map((installed, at) => at === index ? extension : installed); + this.#publish(next); + } + uninstall(extension) { + const current = this.#current.installed(); + if (!current.some((installed) => installed.name === extension.name)) + return; + this.#publish(current.filter((installed) => installed.name !== extension.name)); + } + /** Build and validate the next state, which throws on a task name collision, then publish it synchronously. */ + #publish(extensions) { + this.#current = new RegistryState(extensions); + for (const listener of [...this.#listeners]) + listener(); + } +}; +function validateExtension(extension) { + const tools = /* @__PURE__ */ new Set(); + for (const tool of extension.tools ?? []) { + if (tools.has(tool.name)) + throw new Error(`Extension ${extension.name} has two tools named ${tool.name}`); + tools.add(tool.name); + } + const sections = /* @__PURE__ */ new Set(); + for (const { key } of extension.sections ?? []) { + if (!SECTION_KEY.test(key)) + throw new TypeError(`Section key ${JSON.stringify(key)} must match ${SECTION_KEY}`); + if (key === INSTRUCTIONS_KEY) + throw new Error(`Section key ${key} is reserved for the agent's instructions`); + if (sections.has(key)) + throw new Error(`Extension ${extension.name} has two sections with key ${key}`); + sections.add(key); + } +} +function createRegistry() { + return new RegistryImpl(); +} + +// node_modules/@earendil-works/pi-durable/dist/harness/task-graph.js +var LIVE_STATUSES2 = ["pending", "running", "waiting", "sleeping", "completing"]; +var SCAN_PAGE_SIZE4 = 256; +var TaskGraphView = class { + #session; + #blocking; + #mount; + #closed = false; + constructor(session, blocking) { + this.#session = session; + this.#blocking = blocking; + session.followCommits((publication, context) => { + if (this.#mount === void 0 || publication.kind === "memory") + return; + advance(this.#mount, publication, context, blocking.blocked, (error) => session.report(error)); + }); + blocking.subscribeBlocked(() => this.#refreshBlocked()); + session.subscribeClose(() => { + this.#closed = true; + const ends = closeEnds(this.#session.failure); + for (const observer of [...this.#mount?.observers ?? []]) { + contained(() => observer.end(closeEndOf(observer, ends)), (error) => session.report(error)); + } + this.#mount = void 0; + }); + } + /** + * Attach a synchronous observer of the graph (`Harness.observeTaskGraph()`), behind `guardObserver()`; `context` + * governs only the attachment. + */ + async observe(observer, context) { + let value2; + const attached = await this.#attach((current, release) => { + value2 = current; + return guardObserver(observer, release, (error) => this.#session.report(error)); + }, context); + const signal = context.abortSignal; + if (signal?.aborted) { + attached.detach(); + throw signal.reason; + } + return observation(value2, attached.observer); + } + /** A serialized watch of the graph, an observer of its mount; aborting `context` ends it `aborted`. */ + async watch(context) { + const { observer } = await this.#attach((value2, release) => new Watch(value2, release, (error) => this.#session.report(error)), context); + const signal = context.abortSignal; + if (signal?.aborted) { + observer.abort(); + throw signal.reason; + } + if (signal !== void 0) + observer.observeAbort(signal); + return observer; + } + /** Register an observer created from the current revision, atomically on the Session line. */ + #attach(create, context) { + return this.#session.readOnLine(async (storage) => { + const mount = this.#mount ?? { + ...await this.#build(storage, context), + observers: /* @__PURE__ */ new Set() + }; + const detach = () => { + mount.observers.delete(observer); + if (mount.observers.size === 0 && this.#mount === mount) + this.#mount = void 0; + }; + const observer = create(mount.value, detach); + if (this.#closed) + throw closedError(this.#session); + context.abortSignal?.throwIfAborted(); + this.#mount = mount; + mount.observers.add(observer); + return { observer, detach }; + }); + } + /** On the Session line: derive every node's blocked reason again, and publish the ones that changed. */ + #refreshBlocked() { + if (this.#mount === void 0 || this.#closed) + return; + this.#session.readOnLine(async () => { + const mount = this.#mount; + if (mount === void 0) + return; + const ops = []; + for (const [key, record] of mount.records) { + const blocked = this.#blocking.blocked(record); + if (blocked === mount.value.tasks[key]?.blocked) + continue; + ops.push(blocked === void 0 ? ["d", ["tasks", key, "blocked"]] : ["s", ["tasks", key, "blocked"], blocked]); + } + publish(mount, ops, BACKGROUND_CONTEXT, (error) => this.#session.report(error)); + }).catch(() => { + }); + } + /** Read the graph on the line, through the line's `storage`. */ + async #build(storage, context) { + const tasks = {}; + const live = /* @__PURE__ */ new Map(); + const records = []; + for (const status of LIVE_STATUSES2) { + records.push(...await scanAll((cursor) => storage.scanTasks({ status }, SCAN_PAGE_SIZE4, cursor, context))); + } + records.sort((a, b) => a.id - b.id); + for (const record of records) { + const owned = await scanAll((cursor) => storage.scanConversations({ ownerTaskId: record.id }, SCAN_PAGE_SIZE4, cursor, context)); + const conversations = owned.map((conversation) => conversation.id).sort((a, b) => a - b); + const subject = blockedSubject(record); + tasks[String(record.id)] = nodeOf2(record, conversations, this.#blocking.blocked(subject)); + live.set(String(record.id), subject); + } + return { value: { tasks }, records: live }; + } +}; +function advance(mount, publication, context, blocked, report2) { + const ops = []; + const changed = /* @__PURE__ */ new Map(); + const node = (key) => changed.has(key) ? changed.get(key) : mount.value.tasks[key]; + for (const change of publication.changes) { + if (change.type !== "task") + continue; + const record = change.value; + const key = String(record.id); + const previous = node(key); + if (record.state.status === "terminal") { + mount.records.delete(key); + if (previous === void 0) + continue; + ops.push(["d", ["tasks", key]]); + changed.set(key, void 0); + continue; + } + const subject = blockedSubject(record); + mount.records.set(key, subject); + const next = nodeOf2(record, previous?.conversations ?? [], blocked(subject)); + if (previous !== void 0 && JSON.stringify(next) === JSON.stringify(previous)) + continue; + ops.push(["s", ["tasks", key], next]); + changed.set(key, next); + } + const created = /* @__PURE__ */ new Map(); + for (const change of publication.changes) { + if (change.type !== "conversation" || change.value.owner === void 0) + continue; + const key = String(change.value.owner.taskId); + if (node(key) !== void 0) + created.set(key, [...created.get(key) ?? [], change.value.id]); + } + for (const [key, ids] of created) { + const conversations = [...node(key).conversations, ...ids].sort((a, b) => a - b); + ops.push(["s", ["tasks", key, "conversations"], conversations]); + } + publish(mount, ops, context, report2); +} +function publish(mount, ops, context, report2) { + if (ops.length === 0) + return; + mount.value = applyImmutable(mount.value, ops); + const frameContext = withoutAbortSignal(context); + const call = { kind: "value", value: mount.value, ops, retired: false }; + for (const observer of [...mount.observers]) + contained(() => observer.advance(call, frameContext), report2); +} +function nodeOf2(record, conversations, blocked) { + return { + id: record.id, + kind: record.kind, + conversationId: record.conversationId, + ...record.owner === void 0 ? {} : { owner: record.owner }, + background: record.background, + abortRequested: record.abortRequested, + state: stateOf(record), + conversations, + ...blocked === void 0 ? {} : { blocked } + }; +} +function stateOf(record) { + const state = record.state; + switch (state.status) { + case "pending": + case "running": + return { status: state.status, phase: phaseOf(state.checkpoint) }; + case "waiting": + return { status: "waiting", phase: phaseOf(state.checkpoint), on: [...state.on], policy: state.policy }; + case "sleeping": + return { status: "sleeping", phase: phaseOf(state.checkpoint), until: state.until }; + // Terminal records never reach here: they leave the graph. + case "completing": + case "terminal": + return { status: "completing", outcome: state.outcome.status }; + } +} +function phaseOf(checkpoint) { + return checkpoint.phase; +} + +// node_modules/@earendil-works/pi-durable/dist/harness/view.js +var DEFAULT_WINDOW_MIN = 50; +var MOUNTED = [ + AgentDoc, + LiveDoc, + InboxDoc, + ProviderDoc, + UsageDoc +]; +var MOUNTED_KINDS = new Set(MOUNTED.map((token) => token.definition.kind)); +var SCAN_PAGE_SIZE5 = 256; +var VIEWS = /* @__PURE__ */ new WeakMap(); +function resolveWindow(window) { + const min = window?.min ?? DEFAULT_WINDOW_MIN; + if (!Number.isSafeInteger(min) || min < 1) + throw new RangeError(`Window min must be an integer >= 1, got ${min}`); + const max = window?.max ?? 2 * min; + if (!Number.isSafeInteger(max) || max < min) { + throw new RangeError(`Window max must be an integer >= min (${min}), got ${max}`); + } + const from = window?.from; + if (from !== void 0 && !Number.isSafeInteger(from)) { + throw new RangeError(`Window from must be an entry ID, got ${from}`); + } + return { from, min, max }; +} +var ConversationViews = class { + #session; + #mounts = /* @__PURE__ */ new Map(); + #closed = false; + constructor(session) { + this.#session = session; + VIEWS.set(session, this); + session.followCommits((publication, context) => { + for (const [id, mount] of this.#mounts) + advance2(id, mount, publication, context, (error) => this.report(error)); + }); + session.subscribeClose(() => { + this.#closed = true; + const ends = closeEnds(this.#session.failure); + for (const mount of this.#mounts.values()) + for (const windowed of [...mount.observers]) { + contained(() => windowed.observer.end(closeEndOf(windowed.observer, ends)), (error) => this.report(error)); + } + this.#mounts.clear(); + }); + } + /** Report an observer's listener error through the Harness. */ + report(error) { + this.#session.report(error); + } + /** + * Attach a synchronous observer of the view (`Harness.observeConversation()`), behind `guardObserver()`; `context` + * governs only the attachment. + */ + async observe(id, observer, context, window) { + let value2; + const attached = await this.attach(id, window, (current, release) => { + value2 = current; + return guardObserver(observer, release, (error) => this.report(error)); + }, context); + const signal = context.abortSignal; + if (signal?.aborted) { + attached.detach(); + throw signal.reason; + } + return observation(value2, attached.observer); + } + /** A serialized watch of the view, an observer of its mount; aborting `context` ends it `aborted`. */ + async watch(id, context, window) { + const { observer } = await this.attach(id, window, (value2, release) => new Watch(value2, release, (error) => this.#session.report(error)), context); + const signal = context.abortSignal; + if (signal?.aborted) { + observer.abort(); + throw signal.reason; + } + if (signal !== void 0) + observer.observeAbort(signal); + return observer; + } + /** + * Register an observer created from its window's first revision, atomically on the Session line: it sees every later + * publication and nothing earlier. `release` drops it, and the mount with its last observer. + */ + async attach(id, window, create, context) { + const bounds = resolveWindow(window); + return this.#session.readOnLine(async (storage) => { + const mount = this.#mounts.get(id) ?? await this.#build(storage, id, context); + const { entries: entries2, before } = await loadWindow(storage, id, bounds, context); + const value2 = view(mount.conversation, entries2, before, mount.docs); + let windowed; + const detach = () => { + if (windowed !== void 0) + mount.observers.delete(windowed); + if (mount.observers.size === 0 && this.#mounts.get(id) === mount) + this.#mounts.delete(id); + }; + const observer = create(value2, detach); + if (this.#closed) + throw closedError(this.#session); + context.abortSignal?.throwIfAborted(); + windowed = { min: bounds.min, max: bounds.max, value: value2, observer }; + this.#mounts.set(id, mount); + mount.observers.add(windowed); + return { observer, detach }; + }); + } + /** Hydrate a mount on the line, through the line's `storage`. */ + async #build(storage, id, context) { + const conversation = await storage.conversation(id, context); + if (conversation === void 0) + throw new Error(`Conversation ${id} does not exist`); + const docs = {}; + const incarnations = /* @__PURE__ */ new Map(); + for (const token of MOUNTED) { + const loaded = await this.#session.conversationDocumentOnLine(token, id, context); + if (loaded === void 0) + continue; + docs[token.definition.kind] = loaded.value; + incarnations.set(token.definition.kind, { id: loaded.record.id, version: loaded.version }); + } + return { conversation, docs, incarnations, observers: /* @__PURE__ */ new Set() }; + } +}; +function view(conversation, entries2, before, docs) { + return before === void 0 ? { conversation, entries: entries2, docs } : { conversation, entries: entries2, before, docs }; +} +async function loadWindow(storage, conversationId, bounds, context) { + const { from, min, max } = bounds; + if (from !== void 0) { + const since = await scanUpTo(storage, { conversationId, minEntryId: from, order: "ascending" }, max + 1, context); + if (since.length <= max) { + const query = { conversationId, maxEntryId: idFromNumber(from - 1) }; + const previous = (await storage.scanEntries(query, 1, void 0, context)).items[0]; + return { entries: since, before: previous?.id }; + } + } + const newest = await scanUpTo(storage, { conversationId }, min + 1, context); + const before = newest.length > min ? newest.pop().id : void 0; + return { entries: newest.reverse(), before }; +} +async function scanUpTo(storage, query, limit, context) { + const entries2 = []; + let cursor; + do { + const page2 = await storage.scanEntries(query, Math.min(SCAN_PAGE_SIZE5, limit - entries2.length), cursor, context); + entries2.push(...page2.items); + cursor = page2.next; + } while (cursor !== void 0 && entries2.length < limit); + return entries2; +} +function advance2(id, mount, publication, context, report2) { + const docOps = []; + const appended = []; + for (const change of publication.changes) { + if (change.type === "entry" && change.value.conversationId === id) { + appended.push(change.value); + continue; + } + if (change.type !== "document" || change.conversationId !== id) + continue; + const kind = change.record.kind; + if (!MOUNTED_KINDS.has(kind) || change.record.key !== void 0) + continue; + const path = ["docs", kind]; + const mounted = mount.incarnations.get(kind); + if (change.kind === "retire") { + if (mounted?.id !== change.record.id) + continue; + mount.incarnations.delete(kind); + docOps.push(["d", path]); + } else if (mounted?.id === change.record.id && mounted.version === change.version) { + for (const op2 of change.ops) + docOps.push(prefixed(op2, path)); + } else { + mount.incarnations.set(kind, { id: change.record.id, version: change.version }); + docOps.push(["s", path, change.value]); + } + } + if (docOps.length === 0 && appended.length === 0) + return; + if (docOps.length > 0) + mount.docs = applyImmutable({ docs: mount.docs }, docOps).docs; + const frameContext = withoutAbortSignal(context); + const inserted = appended; + for (const windowed of [...mount.observers]) { + const previous = windowed.value; + let entries2 = previous.entries; + let before = previous.before; + let ops = docOps; + if (appended.length > 0) { + ops = [...docOps, ["p", ["entries"], entries2.length, 0, inserted]]; + entries2 = entries2.concat(appended); + if (entries2.length > windowed.max) { + const drop = entries2.length - windowed.min; + before = entries2[drop - 1].id; + entries2 = entries2.slice(drop); + ops.push(["p", ["entries"], 0, drop, []], ["s", ["before"], before]); + } + } + windowed.value = view(mount.conversation, entries2, before, mount.docs); + const call = { kind: "value", value: windowed.value, ops, retired: false }; + contained(() => windowed.observer.advance(call, frameContext), report2); + } +} +function prefixed(op2, prefix) { + const at = (path) => [...prefix, ...path]; + switch (op2[0]) { + case "r": + return ["s", prefix, op2[1]]; + case "p": + return ["p", at(op2[1]), op2[2], op2[3], op2[4]]; + case "m": + return ["m", at(op2[1]), op2[2]]; + case "s": + return ["s", at(op2[1]), op2[2]]; + case "d": + return ["d", at(op2[1])]; + case "a": + return ["a", at(op2[1]), op2[2]]; + case "t": + return ["t", at(op2[1]), op2[2]]; + } +} + +// node_modules/@earendil-works/pi-durable/dist/harness/harness.js +var SCAN_PAGE_SIZE6 = 256; +var SCHEDULER_INDEX_SIZES = /* @__PURE__ */ Symbol.for("pi-durable.schedulerIndexSizes"); +var SCHEDULER_IDLE_STATE = /* @__PURE__ */ Symbol.for("pi-durable.schedulerIdleState"); +var ConversationImpl = class { + id; + #host; + constructor(id, host) { + this.id = id; + this.#host = host; + } + agent(context) { + return this.#host.harness.resolveAgent(this.id, void 0, context); + } + configure(change, context) { + return this.#host.harness.commitWith((tx) => configure(tx, this.id, change), context); + } + submit(first, second) { + if ("type" in first) + return this.#host.submissions.submit(this.id, first, second); + return this.#host.submissions.admit(first, this.id, second); + } + async compact(instructions, context) { + this.#host.tasks.resume(); + const input = { reason: "manual", ...instructions === void 0 ? {} : { instructions } }; + return this.#host.harness.commitWith((tx) => createCompaction(tx, this.id, input), context); + } + async reset(handoff, context) { + const model2 = handoff === void 0 ? {} : { model: [{ role: "user", content: handoff, timestamp: this.#host.now() }] }; + const entry2 = { kind: ResetEntry.kind, head: "self", ...model2 }; + await this.#host.submissions.submit(this.id, { type: "write", entry: entry2 }, context); + } + commit(change, context, options) { + return this.#host.harness.commitWith(change, context, { conversationId: this.id }, options); + } + context(context, options) { + return readContext(this.#host.harness, this.#host.storage, this.id, context, options?.at); + } + entries(query, limit, cursor, context) { + const scan = conversationEntries(this.id, query); + return this.#host.harness.readOnLine((storage) => storage.scanEntries(scan, limit, cursor, context)); + } + fork(at, options, context) { + return this.#host.create({ kind: "fork", parentId: this.id, at, ownership: options.ownership }, options, context); + } + async abort(context, options) { + this.#host.tasks.resume(); + return this.#host.tasks.abortConversation(this.id, options?.background === true, context); + } + async waitForIdle(context) { + this.#host.tasks.resume(); + return this.#host.tasks.waitForIdle(this.id, context); + } + watch(context, window) { + return this.#host.views.watch(this.id, context, window); + } +}; +var DEFAULT_HARNESS_IDENTITY = { attribution: PI_ATTRIBUTION }; +function currentModels(models2, identity) { + const merged = mergeClientIdentity(DEFAULT_HARNESS_IDENTITY, identity) ?? DEFAULT_HARNESS_IDENTITY; + if (typeof models2 === "function") + return () => withClientIdentity(models2(), merged); + const wrapped = withClientIdentity(models2, merged); + return () => wrapped; +} +function currentSettings(settings) { + return typeof settings === "function" ? settings : () => settings; +} +var HarnessImpl = class extends SessionImpl { + #options; + #hostSettings; + #report; + #host; + #tasks; + #submissions; + #taskGraph; + constructor(rawStorage, options, context) { + const report2 = safeReport(options.onReport, options.telemetry ?? NOOP_TELEMETRY_CONTEXT); + const now = safeNow(options.now, report2); + const hostSettings = currentSettings(options.settings); + super(rawStorage, now, () => hostSettings()?.documentRetentionMs, options.setTimer, options.readOnly === true, () => hostSettings()?.progress?.persistWithinMs); + const storage = this.storage; + this.#options = options; + this.#report = report2; + this.#hostSettings = hostSettings; + const settings = () => resolveSettings(hostSettings()); + this.#tasks = new TaskScheduler({ + telemetry: options.telemetry, + session: this, + storage, + registry: options.registry, + models: currentModels(options.models, options.identity), + agent: (id, snapshot, callContext) => this.resolveAgent(id, snapshot, callContext), + settings, + env: (id, callContext) => this.buildEnv(id, callContext), + now, + onWake: options.onWake, + timers: hostTimers(options.setTimer, (error) => report2(error, { kind: "host_error", callback: "setTimer" })), + writeAtClose: (writes) => this.writeAtClose(writes), + report: this.#report, + limitsRestarts: (kind) => LIMITS_RESTARTS.has(kind), + settleOutcome: settleSchedulerOutcome, + withdrawInputs: withdrawQueuedInputs, + conversation: async (id, binding, callContext) => { + const record = await this.readOnLine((line) => line.conversation(id, callContext)); + return record === void 0 ? void 0 : boundConversation(id, binding, this.#submissions, this.#tasks); + }, + context: withoutAbortSignal(context), + readOnly: options.readOnly === true + }); + this.#submissions = new Submissions(this, now, settings, () => this.#tasks.resume(), options.telemetry); + this.#taskGraph = new TaskGraphView(this, { + blocked: (record) => this.#tasks.blockedReason(record), + subscribeBlocked: (listener) => this.#tasks.subscribeBlocked(listener) + }); + this.#host = { + harness: this, + storage, + tasks: this.#tasks, + submissions: this.#submissions, + views: new ConversationViews(this), + now, + create: (target, createOptions, context2) => this.#create(target, createOptions, context2) + }; + } + /** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */ + async resolveAgent(id, snapshot, context) { + const registry2 = snapshot ?? this.#options.registry.snapshot(); + const state = await this.snapshot(AgentDoc, id, context); + return resolveAgent(state, registry2, resolveSettings(this.#hostSettings()), (error, extension) => this.#report(error, { kind: "extension_error", conversationId: id, extension }, context)); + } + /** Build a conversation's environment from its current `cwd`; `undefined` without an `env` option. */ + async buildEnv(id, context) { + const build = this.#options.env; + if (build === void 0) + return void 0; + const cwd = (await this.snapshot(AgentDoc, id, context))?.cwd; + return build({ conversationId: id, ...cwd === void 0 ? {} : { cwd }, read: this }, context); + } + /** Reconcile surviving `running` tasks to `pending`; part of open. */ + openTasks(context) { + return this.#tasks.open(context); + } + resume() { + this.assertUsable(); + this.#tasks.resume(); + } + wake() { + this.assertUsable(); + return this.#tasks.wake(); + } + getTask(id, context) { + return this.readOnLine((storage) => storage.task(id, context)); + } + /** Internal, for tests: sizes of the scheduler's ownership indexes. */ + get [SCHEDULER_INDEX_SIZES]() { + return this.#tasks.indexSizes; + } + /** Internal, for tests: the kept contexts' idle state and its disagreements with a full recompute. */ + get [SCHEDULER_IDLE_STATE]() { + return this.#tasks.idleState; + } + inspect(context) { + return this.readOnLine(async (storage) => { + const { scheduling, tasks } = await this.#tasks.inspect(this.#options.registry.snapshot()); + const scan = (status) => scanAll((cursor) => storage.scanSubmissions({ status }, SCAN_PAGE_SIZE6, cursor, context)); + const submissions = [...await scan("queued"), ...await scan("placed")].sort((a, b) => a.id - b.id); + return { scheduling, tasks, submissions }; + }); + } + submission(id, context) { + return this.#submissions.get(id, context); + } + abortSubmission(id, context, conversationId) { + return this.#submissions.abort(id, context, conversationId); + } + abortTask(id, context) { + return this.#tasks.abort(id, context); + } + async waitForTask(id, context) { + this.#tasks.resume(); + return this.#tasks.waitForTask(id, context); + } + async waitForIdle(context) { + this.#tasks.resume(); + return this.#tasks.waitForIdle(void 0, context); + } + /** Sum every conversation's committed `pi.usage`. Each document is read at its own point; totals only grow. */ + async usage(context) { + const conversations = await this.readOnLine((storage) => scanAll((cursor) => storage.scanConversations({}, SCAN_PAGE_SIZE6, cursor, context))); + const total = UsageDoc.definition.initial(); + for (const { id } of conversations) { + const state = await this.snapshot(UsageDoc, id, context); + if (state !== void 0) + addUsageState(total, state); + } + return total; + } + watchTaskGraph(context) { + return this.#taskGraph.watch(context); + } + async observeTaskGraph(observer, context) { + this.assertUsable(); + return this.#taskGraph.observe(observer, context); + } + async observeConversation(id, observer, context, window) { + this.assertUsable(); + return this.#host.views.observe(id, observer, context, window); + } + root(context, options) { + return this.#create({ kind: "root" }, options ?? {}, context); + } + async conversation(id, context) { + this.assertUsable(); + const record = await this.readOnLine((storage) => storage.conversation(id, context)); + return record === void 0 ? void 0 : new ConversationImpl(record.id, this.#host); + } + createConversation(options, context) { + return this.#create({ kind: "independent", ownership: options.ownership }, options, context); + } + report(error, info = { kind: "listener_error" }, context) { + this.#report(error, info, context); + } + /** The ownership memberships a commit's task changes write, from the scheduler's index. */ + membershipKeys(tx) { + return this.#tasks.membershipKeys(tx); + } + /** Signal and join task invocations after the close listeners and before Storage closes; writes no task outcome. */ + beforeClose() { + return this.#tasks.join(); + } + async #create(target, options, context) { + this.assertUsable(); + const id = await this.commitWith(async (tx) => { + if (target.kind === "root" && await tx.conversation(ROOT_CONVERSATION_ID) !== void 0) { + return ROOT_CONVERSATION_ID; + } + const record = target.kind === "root" ? await tx.createRootConversation() : target.kind === "fork" ? await tx.forkConversation(target.parentId, target.at, { ownership: target.ownership }) : await tx.createConversation({ ownership: target.ownership }); + if (options.agent !== void 0) + await configure(tx, record.id, options.agent); + if (options.init !== void 0) + await options.init(tx, record.id); + return record.id; + }, context); + return new ConversationImpl(id, this.#host); + } + /** + * The built-in creation hook, in every commit that creates or forks a conversation: empty `pi.live`, `pi.inbox`, and + * `pi.usage`, a fresh `pi.provider`, the conversation's `pi.agent` (see `createAgent()`), then + * `HarnessOptions.conversationCreated`. + */ + async conversationCreated(tx, record) { + await tx.doc(LiveDoc, record.id); + await tx.doc(InboxDoc, record.id); + await tx.doc(UsageDoc, record.id); + await tx.doc(ProviderDoc, record.id); + await createAgent(tx, record); + await this.#options.conversationCreated?.(tx, record); + } +}; +function safeReport(onReport, telemetry) { + const reports = createTypedMetrics(telemetry.metrics ?? NOOP_TELEMETRY_METRICS, [DURABLE_TELEMETRY_SCHEMA]).counter("pi.durable.reports"); + return (error, info, context) => { + if (onReport !== void 0) { + contained(() => onReport({ ...info, error }), () => { + }); + } + const attributes = { + type: info.kind + }; + switch (info.kind) { + case "task_faulted": + case "task_interrupted": + case "task_outdated": + attributes.reason = info.reason; + attributes.extension = info.extension; + attributes["task.id"] = info.task.id; + break; + case "migration_failed": + case "task_error": + case "extension_error": + attributes.extension = info.extension; + attributes["task.id"] = info.task?.id; + break; + case "host_error": + attributes["task.id"] = info.task?.id; + break; + } + try { + reports.add(1, { type: info.kind }); + context?.telemetry.addEvent("pi.durable.report", attributes); + } catch { + } + }; +} +function safeNow(now, report2) { + if (now === void 0) + return Date.now; + let reported = false; + return () => { + try { + return now(); + } catch (error) { + if (!reported) { + reported = true; + report2(error, { kind: "host_error", callback: "now" }); + } + return Date.now(); + } + }; +} +function boundConversation(id, binding, submissions, tasks) { + const bind = (context) => withAbortSignal(binding.signal, context); + const bound = (operation) => { + return async (context) => { + binding.check(); + return operation(bind(context)); + }; + }; + async function submit(first, second) { + binding.check(); + if (!("type" in first)) { + binding.signal.throwIfAborted(); + return submissions.admit(first, id, second); + } + const submission2 = await submissions.submit(id, first, bind(second)); + return { + id: submission2.id, + status: bound((callContext) => submission2.status(callContext)), + wait: bound((callContext) => submission2.wait(callContext)), + abort: bound((callContext) => submission2.abort(callContext)) + }; + } + return { + id, + submit, + abort: async (context, options) => { + binding.check(); + return tasks.abortConversation(id, options?.background === true, bind(context)); + }, + waitForIdle: bound((callContext) => tasks.waitForIdle(id, callContext)) + }; +} +var Harness = { + /** Open a Harness over storage. The registry may keep changing while the Harness runs. */ + async open(storage, options, context) { + context.abortSignal?.throwIfAborted(); + const snapshot = options.registry.snapshot(); + const missing = BUILTIN_TASKS.filter((task) => snapshot.task(task.definition.name) === void 0); + if (missing.length > 0) { + const names2 = missing.map((task) => task.definition.name).join(", "); + throw new Error(`Registry lacks built-in tasks ${names2}; create it with createRegistry()`); + } + const harness2 = new HarnessImpl(storage, options, context); + try { + await harness2.openTasks(context); + } catch (error) { + await harness2.close(withoutAbortSignal(context)).catch(() => { + }); + throw error; + } + return harness2; + } +}; + +// node_modules/@earendil-works/pi-durable/dist/storage/scan.js +function scanStart(requested, cursor, fallback) { + if (requested !== void 0 && requested !== "ascending" && requested !== "descending") { + throw new StorageRequestError(`Invalid scan order: ${String(requested)}`); + } + if (cursor === void 0) + return { order: requested ?? fallback, after: void 0 }; + const { after, order: stored } = cursor; + if (typeof after !== "number" || !Number.isSafeInteger(after)) + throw new StorageRequestError("Invalid storage cursor"); + if (stored !== void 0 && stored !== "ascending" && stored !== "descending") { + throw new StorageRequestError("Invalid storage cursor"); + } + const order = stored ?? fallback; + if (requested !== void 0 && requested !== order) { + throw new StorageRequestError(`The cursor continues a ${order} scan; the query asks for ${requested}`); + } + return { order, after }; +} +function nextCursor(id, order) { + return { after: id, order }; +} + +// node_modules/@earendil-works/chord/dist/sqlite/schema.js +function validateSqliteSchemaPrefix(prefix) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix)) { + throw new Error(`Invalid SQLite schema prefix ${JSON.stringify(prefix)}`); + } + if (prefix.toLowerCase().startsWith("sqlite_")) { + throw new Error(`SQLite schema prefix ${JSON.stringify(prefix)} is reserved`); + } +} + +// node_modules/@earendil-works/chord/dist/sqlite/migrations.js +async function applySqliteMigrations(database, migrations, options) { + const { table } = options; + validateSqliteSchemaPrefix(table); + for (let index = 0; index < migrations.length; index++) { + if (migrations[index]?.version !== index + 1) { + throw new Error("SQLite migrations must have contiguous versions starting at 1"); + } + } + await database.transaction(async (transaction) => { + await transaction.exec(`CREATE TABLE IF NOT EXISTS ${table} ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + version INTEGER NOT NULL CHECK (version >= 0) + ) STRICT`); + await transaction.run(`INSERT OR IGNORE INTO ${table} (singleton, version) VALUES (1, 0)`); + const row = await transaction.get(`SELECT version FROM ${table} WHERE singleton = 1`); + if (row === void 0) + throw new Error("SQLite schema metadata is missing"); + const currentVersion = migrations.at(-1)?.version ?? 0; + if (row.version > currentVersion) { + throw new Error(`SQLite schema version ${row.version} is newer than supported version ${currentVersion}`); + } + for (const migration of migrations) { + if (migration.version <= row.version) + continue; + for (const statement of migration.statements) + await transaction.exec(statement); + await transaction.run(`UPDATE ${table} SET version = ? WHERE singleton = 1`, migration.version); + } + }); +} + +// node_modules/@earendil-works/pi-durable/dist/storage/sqlite/schema.js +var TABLE_NAMES = { + durableSchema: "durable_schema", + durableMetadata: "durable_metadata", + recordIds: "record_ids", + conversations: "conversations", + entries: "entries", + tasks: "tasks", + submissions: "submissions", + documents: "documents", + documentRevisions: "document_revisions" +}; +var INDEX_NAMES = { + conversationsByOwnerConversation: "conversations_by_owner_conversation", + conversationsByOwnerTask: "conversations_by_owner_task", + entriesByConversation: "entries_by_conversation", + entryHeadsByConversation: "entry_heads_by_conversation", + tasksByStatus: "tasks_by_status", + tasksByConversation: "tasks_by_conversation", + tasksByKind: "tasks_by_kind", + tasksByAbortRequested: "tasks_by_abort_requested", + tasksByBackground: "tasks_by_background", + submissionsByRequest: "submissions_by_request", + submissionsByConversation: "submissions_by_conversation", + submissionsByStatus: "submissions_by_status", + documentsByAddress: "documents_by_address", + documentsByScope: "documents_by_scope", + documentsByScopeKind: "documents_by_scope_kind", + documentRevisionsByKind: "document_revisions_by_kind" +}; +function prefixIdentifiers(names2, prefix) { + return Object.fromEntries(Object.entries(names2).map(([key, name]) => [key, `${prefix}${name}`])); +} +function createSqliteSchema(prefix) { + if (prefix !== void 0) + validateSqliteSchemaPrefix(prefix); + const actualPrefix = prefix ?? ""; + return { + tables: prefixIdentifiers(TABLE_NAMES, actualPrefix), + indexes: prefixIdentifiers(INDEX_NAMES, actualPrefix) + }; +} +var DEFAULT_SQLITE_SCHEMA = createSqliteSchema(); + +// node_modules/@earendil-works/pi-durable/dist/storage/sqlite/migrations.js +function createSqliteMigrations(schema) { + const { tables, indexes } = schema; + const initialSchema = [ + `CREATE TABLE ${tables.durableMetadata} ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + next_id TEXT NOT NULL, + next_seq INTEGER NOT NULL + ) STRICT`, + `INSERT INTO ${tables.durableMetadata} (singleton, next_id, next_seq) VALUES (1, '2', 1)`, + `CREATE TABLE ${tables.recordIds} ( + id INTEGER PRIMARY KEY, + record_type TEXT NOT NULL CHECK (record_type IN ('conversation', 'entry', 'task', 'submission', 'document')) + ) STRICT`, + `CREATE TABLE ${tables.conversations} ( + id INTEGER PRIMARY KEY, + owner_conversation_id INTEGER, + owner_task_id INTEGER, + record TEXT NOT NULL CHECK (json_valid(record)) + ) STRICT`, + `CREATE INDEX ${indexes.conversationsByOwnerConversation} ON ${tables.conversations} (owner_conversation_id, id)`, + `CREATE INDEX ${indexes.conversationsByOwnerTask} ON ${tables.conversations} (owner_task_id, id)`, + `CREATE TABLE ${tables.entries} ( + id INTEGER PRIMARY KEY, + conversation_id INTEGER NOT NULL, + head INTEGER, + commit_seq INTEGER NOT NULL, + record TEXT NOT NULL CHECK (json_valid(record)) + ) STRICT`, + `CREATE INDEX ${indexes.entriesByConversation} ON ${tables.entries} (conversation_id, id DESC)`, + `CREATE INDEX ${indexes.entryHeadsByConversation} ON ${tables.entries} (conversation_id, id DESC) WHERE head IS NOT NULL`, + `CREATE TABLE ${tables.tasks} ( + id INTEGER PRIMARY KEY, + conversation_id INTEGER NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'waiting', 'completing', 'terminal')), + abort_requested INTEGER NOT NULL CHECK (abort_requested IN (0, 1)), + background INTEGER NOT NULL CHECK (background IN (0, 1)), + record TEXT NOT NULL CHECK (json_valid(record)) + ) STRICT`, + `CREATE INDEX ${indexes.tasksByStatus} ON ${tables.tasks} (status, id)`, + `CREATE INDEX ${indexes.tasksByConversation} ON ${tables.tasks} (conversation_id, id)`, + `CREATE INDEX ${indexes.tasksByKind} ON ${tables.tasks} (kind, id)`, + `CREATE INDEX ${indexes.tasksByAbortRequested} ON ${tables.tasks} (abort_requested, id)`, + `CREATE INDEX ${indexes.tasksByBackground} ON ${tables.tasks} (background, id)`, + `CREATE TABLE ${tables.submissions} ( + id INTEGER PRIMARY KEY, + conversation_id INTEGER NOT NULL, + request_id TEXT, + status TEXT NOT NULL CHECK (status IN ('queued', 'placed', 'done', 'unanswered')), + record TEXT NOT NULL CHECK (json_valid(record)) + ) STRICT`, + `CREATE INDEX ${indexes.submissionsByRequest} ON ${tables.submissions} (conversation_id, request_id)`, + `CREATE INDEX ${indexes.submissionsByConversation} ON ${tables.submissions} (conversation_id, id)`, + `CREATE INDEX ${indexes.submissionsByStatus} ON ${tables.submissions} (status, id)`, + `CREATE TABLE ${tables.documents} ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL, + family INTEGER NOT NULL CHECK (family IN (0, 1)), + key_value TEXT NOT NULL, + scope_kind TEXT NOT NULL CHECK (scope_kind IN ('session', 'conversation', 'task')), + owner_id INTEGER NOT NULL, + created_at INTEGER NOT NULL, + retired_at INTEGER, + record TEXT NOT NULL CHECK (json_valid(record)) + ) STRICT`, + `CREATE INDEX ${indexes.documentsByAddress} + ON ${tables.documents} (kind, scope_kind, owner_id, family, key_value, created_at DESC, retired_at)`, + `CREATE INDEX ${indexes.documentsByScope} ON ${tables.documents} (scope_kind, owner_id, id)`, + `CREATE INDEX ${indexes.documentsByScopeKind} ON ${tables.documents} (scope_kind, owner_id, kind, id)`, + `CREATE TABLE ${tables.documentRevisions} ( + document_id INTEGER NOT NULL, + seq INTEGER NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('base', 'delta')), + version INTEGER NOT NULL, + content TEXT NOT NULL CHECK (json_valid(content)), + PRIMARY KEY (document_id, seq) + ) STRICT`, + `CREATE INDEX ${indexes.documentRevisionsByKind} ON ${tables.documentRevisions} (document_id, kind, seq DESC)` + ]; + const rebuiltTasks = `${tables.tasks}_v2`; + const sleepingStatus = [ + `CREATE TABLE ${rebuiltTasks} ( + id INTEGER PRIMARY KEY, + conversation_id INTEGER NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'waiting', 'sleeping', 'completing', 'terminal')), + abort_requested INTEGER NOT NULL CHECK (abort_requested IN (0, 1)), + background INTEGER NOT NULL CHECK (background IN (0, 1)), + record TEXT NOT NULL CHECK (json_valid(record)) + ) STRICT`, + `INSERT INTO ${rebuiltTasks} (id, conversation_id, kind, status, abort_requested, background, record) + SELECT id, conversation_id, kind, status, abort_requested, background, record FROM ${tables.tasks}`, + `DROP TABLE ${tables.tasks}`, + `ALTER TABLE ${rebuiltTasks} RENAME TO ${tables.tasks}`, + `CREATE INDEX ${indexes.tasksByStatus} ON ${tables.tasks} (status, id)`, + `CREATE INDEX ${indexes.tasksByConversation} ON ${tables.tasks} (conversation_id, id)`, + `CREATE INDEX ${indexes.tasksByKind} ON ${tables.tasks} (kind, id)`, + `CREATE INDEX ${indexes.tasksByAbortRequested} ON ${tables.tasks} (abort_requested, id)`, + `CREATE INDEX ${indexes.tasksByBackground} ON ${tables.tasks} (background, id)` + ]; + return [ + { version: 1, statements: initialSchema }, + { version: 2, statements: sleepingStatus } + ]; +} +var SQLITE_MIGRATIONS = createSqliteMigrations(DEFAULT_SQLITE_SCHEMA); +var CURRENT_SQLITE_SCHEMA_VERSION = SQLITE_MIGRATIONS.at(-1)?.version ?? 0; +async function applySqliteMigrationsForSchema(database, schema, migrations = createSqliteMigrations(schema)) { + await applySqliteMigrations(database, migrations, { table: schema.tables.durableSchema }); +} + +// node_modules/@earendil-works/pi-durable/dist/storage/sqlite/storage.js +var parseJson = (value2) => JSON.parse(value2); +var encodeJson = (value2) => JSON.stringify(value2); +var encodeIndexedString = (value2) => JSON.stringify(value2); +var cursorId = (cursor) => { + const after = cursor?.after; + if (after === void 0) + return void 0; + if (typeof after !== "number" || !Number.isSafeInteger(after)) + throw new StorageRequestError("Invalid storage cursor"); + return idFromNumber(after); +}; +var page = (values, limit, order = "ascending") => { + const items = values.slice(0, limit); + if (values.length <= limit) + return { items }; + return { items, next: nextCursor(items.at(-1).id, order) }; +}; +var scanSql = (start) => start.order === "ascending" ? { clause: "id > ?", param: start.after ?? -1, direction: "ASC" } : { clause: "id < ?", param: start.after ?? Number.MAX_SAFE_INTEGER, direction: "DESC" }; +var scopeColumns = (scope) => { + switch (scope.kind) { + case "session": + return { scopeKind: "session", ownerId: 0 }; + case "conversation": + return { scopeKind: "conversation", ownerId: scope.conversationId }; + case "task": + return { scopeKind: "task", ownerId: scope.taskId }; + } +}; +var addressParts = (address) => { + const scope = scopeColumns(address.scope); + return { + kind: encodeIndexedString(address.kind), + ...scope, + family: address.key === void 0 ? 0 : 1, + keyValue: encodeIndexedString(address.key ?? "") + }; +}; +var addressKey = (address) => { + const parts = addressParts(address); + return JSON.stringify([parts.kind, parts.scopeKind, parts.ownerId, parts.family, parts.keyValue]); +}; +var isAliveAt = (record, at) => { + if (at === "current") + return record.retiredAt === void 0; + return record.createdAt <= at && (record.retiredAt === void 0 || at < record.retiredAt); +}; +var isCurrentOnly = (record) => record.scope.kind !== "conversation" || record.history === "latest"; +var writeId = (write) => { + switch (write.type) { + case "conversation": + case "entry": + case "task": + case "submission": + return write.value.id; + case "document.create": + case "document.copy": + return write.record.id; + case "document.change": + case "document.retire": + return void 0; + } +}; +function watchCalls(executor, calls) { + const watched = async (call) => { + try { + return await call(); + } catch (error) { + calls.failed = true; + throw error; + } + }; + return { + exec: (sql) => watched(() => executor.exec(sql)), + run: (sql, ...params) => watched(() => executor.run(sql, ...params)), + runCounted: (sql, ...params) => watched(() => executor.runCounted(sql, ...params)), + get: (sql, ...params) => watched(() => executor.get(sql, ...params)), + all: (sql, ...params) => watched(() => executor.all(sql, ...params)) + }; +} +var SqliteStorage = class _SqliteStorage { + db; + schema; + nextId; + closed = false; + closing; + admittedReads = 0; + readsDrained; + constructor(db, schema, nextId) { + this.db = db; + this.schema = schema; + this.nextId = nextId; + } + /** Initialize storage over an owned SQLite database facade. */ + static async open(db, options = {}) { + try { + const schema = createSqliteSchema(options.prefix); + await applySqliteMigrationsForSchema(db, schema); + const metadata = await db.get(`SELECT next_id, next_seq FROM ${schema.tables.durableMetadata} WHERE singleton = 1`); + if (metadata === void 0) + throw new Error("Durable SQLite metadata is missing"); + return new _SqliteStorage(db, schema, Number(metadata.next_id)); + } catch (error) { + try { + await db.close(); + } catch { + } + throw error; + } + } + /** + * Commit `batches` in one SQL transaction, each with its own seq. Each batch is checked before it writes anything, + * against the state the earlier ones left, and the first one refused ends the batch: the transaction commits the ones + * before it. No savepoints, which Durable Objects do not allow: the checks run before the writes instead. + */ + async commit(batches, receive, _context) { + this.assertOpen(); + const seqs = []; + let refused; + let candidateNextId = this.nextId; + await this.db.transaction(async (transaction) => { + const metadata = await this.readMetadata(transaction); + let next = metadata.next_seq; + const calls = { failed: false }; + const checked = watchCalls(transaction, calls); + for (const writes of batches) { + let documentActions; + calls.failed = false; + try { + documentActions = this.prepareDocumentActions(writes); + await this.checkBatch(checked, writes, documentActions); + } catch (error) { + if (calls.failed) + throw error; + refused = { error }; + break; + } + const seq = seqFromNumber(next++); + await this.applyBatch(transaction, writes, documentActions, seq); + seqs.push(seq); + candidateNextId = Math.max(candidateNextId, this.candidateNextId(writes)); + } + if (seqs.length > 0) { + await this.writeMetadata(transaction, Math.max(Number(metadata.next_id), candidateNextId), next); + } + }); + this.nextId = Math.max(this.nextId, candidateNextId); + for (const [index, seq] of seqs.entries()) + receive(index, seq); + return refused === void 0 ? { stored: seqs.length } : { stored: seqs.length, refused: refused.error }; + } + async readMetadata(executor) { + const metadata = await executor.get(`SELECT next_id, next_seq FROM ${this.schema.tables.durableMetadata} WHERE singleton = 1`); + if (metadata === void 0) + throw new Error("Durable SQLite metadata is missing"); + return metadata; + } + async writeMetadata(executor, nextId, nextSeq) { + await executor.run(`UPDATE ${this.schema.tables.durableMetadata} SET next_id = ?, next_seq = ? WHERE singleton = 1`, String(nextId), nextSeq); + } + /** Durable's own checks of one batch, copy resolution included, before it writes anything. */ + async checkBatch(executor, writes, documentActions) { + await this.checkGlobalIds(executor, writes); + await this.checkDocumentActions(executor, documentActions); + await this.resolveCopies(executor, documentActions); + } + async applyBatch(executor, writes, documentActions, seq) { + for (const write of writes) + await this.applyTableWrite(executor, write, seq); + await this.applyDocumentActions(executor, documentActions, seq); + } + async mintId() { + this.assertOpen(); + if (!Number.isSafeInteger(this.nextId)) + throw new Error("ID space is exhausted"); + return idFromNumber(this.nextId++); + } + async conversation(id, _context) { + this.assertOpen(); + const row = await this.db.get(`SELECT record FROM ${this.schema.tables.conversations} WHERE id = ?`, id); + return row === void 0 ? void 0 : parseJson(row.record); + } + async scanConversations(query, limit, cursor, _context) { + this.assertOpen(); + const start = scanStart(query.order, cursor, "ascending"); + const scan = scanSql(start); + const clauses = [scan.clause]; + const params = [scan.param]; + if (query.ownerConversationId !== void 0) { + clauses.push("owner_conversation_id = ?"); + params.push(query.ownerConversationId); + } + if (query.ownerTaskId !== void 0) { + clauses.push("owner_task_id = ?"); + params.push(query.ownerTaskId); + } + params.push(limit + 1); + const rows = await this.db.all(`SELECT record FROM ${this.schema.tables.conversations} WHERE ${clauses.join(" AND ")} ORDER BY id ${scan.direction} LIMIT ?`, ...params); + return page(rows.map((row) => parseJson(row.record)), limit, start.order); + } + entry(idOrConversationId, idOrContext, context) { + return this.admitRead(() => this.readEntry(idOrConversationId, idOrContext, context)); + } + findLatestHeadMarker(conversationId, atOrBeforeEntryId, _context) { + return this.admitRead(() => this.readLatestHeadMarker(conversationId, atOrBeforeEntryId)); + } + scanEntries(query, limit, cursor, _context) { + return this.admitRead(() => this.readEntries(query, limit, cursor)); + } + async readEntry(idOrConversationId, idOrContext, context) { + const id = context === void 0 ? idFromNumber(idOrConversationId) : typeof idOrContext === "number" ? idFromNumber(idOrContext) : void 0; + if (id === void 0) + throw new StorageRequestError("Storage.entry() requires an entry ID"); + let conversation; + if (context !== void 0) { + const conversationId = idFromNumber(idOrConversationId); + conversation = await this.readConversation(conversationId); + if (conversation === void 0) + throw new StorageRequestError(`Unknown conversation: ${conversationId}`); + } + const row = await this.db.get(`SELECT record, commit_seq FROM ${this.schema.tables.entries} WHERE id = ?`, id); + if (row === void 0) + return void 0; + const entry2 = parseJson(row.record); + if (conversation !== void 0) { + let upperEntryId = Number.POSITIVE_INFINITY; + while (conversation.id !== entry2.conversationId) { + if (conversation.parent === void 0) + return void 0; + upperEntryId = Math.min(upperEntryId, conversation.parent.at); + conversation = await this.readConversation(conversation.parent.conversationId); + } + if (entry2.id > upperEntryId) + return void 0; + } + return { entry: entry2, commitSeq: seqFromNumber(row.commit_seq) }; + } + async readLatestHeadMarker(conversationId, atOrBeforeEntryId) { + let conversation = await this.readConversation(conversationId); + if (conversation === void 0) + throw new StorageRequestError(`Unknown conversation: ${conversationId}`); + let upper = atOrBeforeEntryId; + while (true) { + const row = upper === void 0 ? await this.db.get(`SELECT record FROM ${this.schema.tables.entries} WHERE conversation_id = ? AND head IS NOT NULL ORDER BY id DESC LIMIT 1`, conversation.id) : await this.db.get(`SELECT record FROM ${this.schema.tables.entries} WHERE conversation_id = ? AND head IS NOT NULL AND id <= ? ORDER BY id DESC LIMIT 1`, conversation.id, upper); + if (row !== void 0) + return parseJson(row.record); + if (conversation.parent === void 0) + return void 0; + upper = upper === void 0 ? conversation.parent.at : Math.min(upper, conversation.parent.at); + conversation = await this.readConversation(conversation.parent.conversationId); + } + } + async readEntries(query, limit, cursor) { + const { order, after } = scanStart(query.order, cursor, "descending"); + if (order === "ascending") + return this.readEntriesAscending(query, limit, after); + let conversation = await this.readConversation(query.conversationId); + if (conversation === void 0) + throw new StorageRequestError(`Unknown conversation: ${query.conversationId}`); + let upper = query.maxEntryId; + if (after !== void 0) + upper = Math.min(upper ?? Number.MAX_SAFE_INTEGER, after - 1); + const values = []; + while (true) { + const clauses = ["conversation_id = ?"]; + const params = [conversation.id]; + if (query.minEntryId !== void 0) { + clauses.push("id >= ?"); + params.push(query.minEntryId); + } + if (upper !== void 0) { + clauses.push("id <= ?"); + params.push(upper); + } + params.push(limit + 1 - values.length); + const rows = await this.db.all(`SELECT record FROM ${this.schema.tables.entries} WHERE ${clauses.join(" AND ")} ORDER BY id DESC LIMIT ?`, ...params); + values.push(...rows.map((row) => parseJson(row.record))); + if (values.length > limit || conversation.parent === void 0) + break; + upper = upper === void 0 ? conversation.parent.at : Math.min(upper, conversation.parent.at); + if (query.minEntryId !== void 0 && upper < query.minEntryId) + break; + conversation = await this.readConversation(conversation.parent.conversationId); + } + return page(values, limit, "descending"); + } + /** Oldest first: the fork chain's segments from the root conversation forward, each up to its fork point. */ + async readEntriesAscending(query, limit, after) { + const segments = []; + let conversation = await this.readConversation(query.conversationId); + if (conversation === void 0) + throw new StorageRequestError(`Unknown conversation: ${query.conversationId}`); + let upper = query.maxEntryId; + while (true) { + segments.push({ conversationId: conversation.id, upper }); + if (conversation.parent === void 0) + break; + upper = upper === void 0 ? conversation.parent.at : Math.min(upper, conversation.parent.at); + if (query.minEntryId !== void 0 && upper < query.minEntryId) + break; + conversation = await this.readConversation(conversation.parent.conversationId); + } + let lower = query.minEntryId; + if (after !== void 0) + lower = Math.max(lower ?? after + 1, after + 1); + const values = []; + for (const segment of segments.reverse()) { + const clauses = ["conversation_id = ?"]; + const params = [segment.conversationId]; + if (lower !== void 0) { + clauses.push("id >= ?"); + params.push(lower); + } + if (segment.upper !== void 0) { + clauses.push("id <= ?"); + params.push(segment.upper); + } + params.push(limit + 1 - values.length); + const rows = await this.db.all(`SELECT record FROM ${this.schema.tables.entries} WHERE ${clauses.join(" AND ")} ORDER BY id ASC LIMIT ?`, ...params); + values.push(...rows.map((row) => parseJson(row.record))); + if (values.length > limit) + break; + } + return page(values, limit, "ascending"); + } + async task(id, _context) { + this.assertOpen(); + const row = await this.db.get(`SELECT record FROM ${this.schema.tables.tasks} WHERE id = ?`, id); + return row === void 0 ? void 0 : parseJson(row.record); + } + async scanTasks(query, limit, cursor, _context) { + this.assertOpen(); + const start = scanStart(query.order, cursor, "ascending"); + const scan = scanSql(start); + const clauses = [scan.clause]; + const params = [scan.param]; + if (query.conversationId !== void 0) { + clauses.push("conversation_id = ?"); + params.push(query.conversationId); + } + if (query.kind !== void 0) { + clauses.push("kind = ?"); + params.push(encodeIndexedString(query.kind)); + } + if (query.status !== void 0) { + clauses.push("status = ?"); + params.push(query.status); + } + if (query.abortRequested !== void 0) { + clauses.push("abort_requested = ?"); + params.push(query.abortRequested ? 1 : 0); + } + if (query.background !== void 0) { + clauses.push("background = ?"); + params.push(query.background ? 1 : 0); + } + params.push(limit + 1); + const rows = await this.db.all(`SELECT record FROM ${this.schema.tables.tasks} WHERE ${clauses.join(" AND ")} ORDER BY id ${scan.direction} LIMIT ?`, ...params); + return page(rows.map((row) => parseJson(row.record)), limit, start.order); + } + async submission(id, _context) { + this.assertOpen(); + const row = await this.db.get(`SELECT record FROM ${this.schema.tables.submissions} WHERE id = ?`, id); + return row === void 0 ? void 0 : parseJson(row.record); + } + async scanSubmissions(query, limit, cursor, _context) { + this.assertOpen(); + const start = scanStart(query.order, cursor, "ascending"); + const scan = scanSql(start); + const clauses = [scan.clause]; + const params = [scan.param]; + if (query.conversationId !== void 0) { + clauses.push("conversation_id = ?"); + params.push(query.conversationId); + } + if (query.status !== void 0) { + clauses.push("status = ?"); + params.push(query.status); + } + params.push(limit + 1); + const rows = await this.db.all(`SELECT record FROM ${this.schema.tables.submissions} WHERE ${clauses.join(" AND ")} ORDER BY id ${scan.direction} LIMIT ?`, ...params); + return page(rows.map((row) => parseJson(row.record)), limit, start.order); + } + async submissionByRequest(conversationId, requestId, _context) { + this.assertOpen(); + const row = await this.db.get(`SELECT record FROM ${this.schema.tables.submissions} WHERE conversation_id = ? AND request_id = ?`, conversationId, encodeIndexedString(requestId)); + return row === void 0 ? void 0 : parseJson(row.record); + } + async findDocument(address, at, _context) { + this.assertOpen(); + const parts = addressParts(address); + const sql = at === "current" ? `SELECT record FROM ${this.schema.tables.documents} + WHERE kind = ? AND scope_kind = ? AND owner_id = ? AND family = ? AND key_value = ? + AND retired_at IS NULL ORDER BY created_at DESC LIMIT 1` : `SELECT record FROM ${this.schema.tables.documents} + WHERE kind = ? AND scope_kind = ? AND owner_id = ? AND family = ? AND key_value = ? + AND created_at <= ? AND (retired_at IS NULL OR retired_at > ?) + ORDER BY created_at DESC LIMIT 1`; + const params = [parts.kind, parts.scopeKind, parts.ownerId, parts.family, parts.keyValue]; + if (at !== "current") + params.push(at, at); + const row = await this.db.get(sql, ...params); + return row === void 0 ? void 0 : parseJson(row.record); + } + async document(id, at, _context) { + this.assertOpen(); + return this.db.transaction((transaction) => this.materializeDocument(transaction, id, at)); + } + async scanDocuments(query, limit, cursor, _context) { + this.assertOpen(); + const scope = scopeColumns(query.scope); + const clauses = ["scope_kind = ?", "owner_id = ?", "id > ?"]; + const params = [scope.scopeKind, scope.ownerId, cursorId(cursor) ?? -1]; + if (query.kind !== void 0) { + clauses.push("kind = ?"); + params.push(encodeIndexedString(query.kind)); + } + if (query.at === "current") { + clauses.push("retired_at IS NULL"); + } else { + clauses.push("created_at <= ?", "(retired_at IS NULL OR retired_at > ?)"); + params.push(query.at, query.at); + } + params.push(limit + 1); + const rows = await this.db.all(`SELECT record FROM ${this.schema.tables.documents} WHERE ${clauses.join(" AND ")} ORDER BY id LIMIT ?`, ...params); + return page(rows.map((row) => parseJson(row.record)), limit); + } + close(_context) { + if (this.closing === void 0) { + this.closed = true; + this.closing = this.closeDatabase(); + } + return this.closing; + } + async closeDatabase() { + if (this.admittedReads > 0) { + await new Promise((resolve2) => { + this.readsDrained = resolve2; + }); + } + await this.db.close(); + } + /** + * Run a read that issues several queries. Close waits for admitted reads, so their later queries never reach a + * closed database. Single-query reads and transactions are already ordered before close by the database. + */ + async admitRead(read2) { + this.assertOpen(); + this.admittedReads++; + try { + return await read2(); + } finally { + if (--this.admittedReads === 0) + this.readsDrained?.(); + } + } + async readConversation(id) { + const row = await this.db.get(`SELECT record FROM ${this.schema.tables.conversations} WHERE id = ?`, id); + return row === void 0 ? void 0 : parseJson(row.record); + } + async materializeDocument(executor, id, at) { + const row = await executor.get(`SELECT record FROM ${this.schema.tables.documents} WHERE id = ?`, id); + if (row === void 0) + return void 0; + const record = parseJson(row.record); + if (at !== "current" && isCurrentOnly(record)) { + throw new StorageRequestError(`Document ${id} does not retain historical content`); + } + if (!isAliveAt(record, at)) + return void 0; + const upper = at === "current" ? Number.MAX_SAFE_INTEGER : at; + const base = await executor.get(`SELECT seq, kind, version, content FROM ${this.schema.tables.documentRevisions} + WHERE document_id = ? AND kind = 'base' AND seq <= ? ORDER BY seq DESC LIMIT 1`, id, upper); + if (base === void 0) + throw new Error(`Document ${id} is missing a required base`); + let value2 = parseJson(base.content); + const tail = await executor.all(`SELECT seq, kind, version, content FROM ${this.schema.tables.documentRevisions} + WHERE document_id = ? AND seq > ? AND seq <= ? ORDER BY seq`, id, base.seq, upper); + for (const revision of tail) { + if (revision.kind !== "delta" || revision.version !== base.version) { + throw new Error(`Document ${id} crosses a stored version boundary without a base`); + } + value2 = apply(value2, parseJson(revision.content)); + } + return { record, version: base.version, value: value2, deltasSinceBase: tail.length }; + } + candidateNextId(writes) { + let nextId = this.nextId; + for (const write of writes) { + const id = writeId(write); + if (id !== void 0) + nextId = Math.max(nextId, id + 1); + } + return nextId; + } + async checkGlobalIds(executor, writes) { + const claimed = /* @__PURE__ */ new Map(); + for (const write of writes) { + if (write.type === "document.change" || write.type === "document.retire") + continue; + const document = write.type === "document.create" || write.type === "document.copy"; + const table = document ? "document" : write.type; + const id = document ? write.record.id : write.value.id; + const existing = (await executor.get(`SELECT record_type FROM ${this.schema.tables.recordIds} WHERE id = ?`, id))?.record_type; + const earlier = claimed.get(id); + if (table === "conversation" || table === "entry" || table === "document") { + if (existing !== void 0) + throw new Error(`ID ${id} already belongs to ${existing}`); + if (earlier !== void 0) + throw new Error(`ID ${id} is written more than once`); + } else { + if (existing !== void 0 && existing !== table) + throw new Error(`ID ${id} already belongs to ${existing}`); + if (earlier !== void 0 && earlier !== table) + throw new Error(`ID ${id} is written as two record types`); + } + claimed.set(id, table); + } + } + prepareDocumentActions(writes) { + const actions = /* @__PURE__ */ new Map(); + for (const write of writes) { + if (write.type !== "document.create" && write.type !== "document.copy" && write.type !== "document.change" && write.type !== "document.retire") { + continue; + } + const id = write.type === "document.create" || write.type === "document.copy" ? write.record.id : write.id; + let action = actions.get(id); + if (action === void 0) { + action = { retire: false }; + actions.set(id, action); + } + switch (write.type) { + case "document.create": + if (action.create !== void 0 || action.content !== void 0 || action.copy !== void 0) { + throw new Error(`Document ${id} has more than one content command`); + } + action.create = write.record; + action.content = write.content; + break; + case "document.copy": + if (action.create !== void 0 || action.content !== void 0 || action.copy !== void 0) { + throw new Error(`Document ${id} has more than one content command`); + } + action.create = write.record; + action.copy = write.source; + break; + case "document.change": + if (action.content !== void 0 || action.copy !== void 0) { + throw new Error(`Document ${id} has more than one content command`); + } + action.content = write.content; + break; + case "document.retire": + if (action.retire) + throw new Error(`Document ${id} is retired more than once`); + action.retire = true; + break; + } + } + return actions; + } + async checkDocumentActions(executor, actions) { + const liveCounts = /* @__PURE__ */ new Map(); + for (const [id, action] of actions) { + if (action.copy !== void 0 && actions.has(action.copy.id)) { + throw new Error(`Document copy ${id} source is changed in the copy batch`); + } + const row = await executor.get(`SELECT record FROM ${this.schema.tables.documents} WHERE id = ?`, id); + const existing = row === void 0 ? void 0 : parseJson(row.record); + if (action.create === void 0 && existing === void 0) + throw new Error(`Unknown document: ${id}`); + if (action.create !== void 0 && existing !== void 0) + throw new Error(`Document ${id} already exists`); + if (existing?.retiredAt !== void 0) + throw new Error(`Document ${id} is retired`); + if (action.content?.kind === "delta") { + const previous = await executor.get(`SELECT version FROM ${this.schema.tables.documentRevisions} WHERE document_id = ? ORDER BY seq DESC LIMIT 1`, id); + if (previous === void 0) + throw new Error(`Document ${id} delta has no base`); + if (previous.version !== action.content.version) { + throw new Error(`Document ${id} version transition requires a base`); + } + } + const record = action.create ?? existing; + const key = addressKey(record); + let live = liveCounts.get(key); + if (live === void 0) + live = await this.currentDocumentId(executor, record) === void 0 ? 0 : 1; + if (action.retire && existing !== void 0) + live--; + if (action.create !== void 0 && !action.retire) + live++; + liveCounts.set(key, live); + } + for (const live of liveCounts.values()) { + if (live > 1) + throw new Error("Document address already has a current incarnation"); + } + } + async currentDocumentId(executor, address) { + const parts = addressParts(address); + const id = (await executor.get(`SELECT id FROM ${this.schema.tables.documents} + WHERE kind = ? AND scope_kind = ? AND owner_id = ? AND family = ? AND key_value = ? AND retired_at IS NULL + LIMIT 1`, parts.kind, parts.scopeKind, parts.ownerId, parts.family, parts.keyValue))?.id; + return id === void 0 ? void 0 : idFromNumber(id); + } + async applyTableWrite(executor, write, seq) { + switch (write.type) { + case "conversation": + await this.claimId(executor, write.value.id, "conversation"); + await executor.run(`INSERT INTO ${this.schema.tables.conversations} (id, owner_conversation_id, owner_task_id, record) VALUES (?, ?, ?, ?)`, write.value.id, write.value.owner?.conversationId ?? null, write.value.owner?.taskId ?? null, encodeJson(write.value)); + break; + case "entry": + await this.claimId(executor, write.value.id, "entry"); + await executor.run(`INSERT INTO ${this.schema.tables.entries} (id, conversation_id, head, commit_seq, record) VALUES (?, ?, ?, ?, ?)`, write.value.id, write.value.conversationId, write.value.head ?? null, seq, encodeJson(write.value)); + break; + case "task": + await this.claimId(executor, write.value.id, "task"); + await executor.run(`INSERT INTO ${this.schema.tables.tasks} (id, conversation_id, kind, status, abort_requested, background, record) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET conversation_id = excluded.conversation_id, kind = excluded.kind, + status = excluded.status, abort_requested = excluded.abort_requested, + background = excluded.background, record = excluded.record`, write.value.id, write.value.conversationId, encodeIndexedString(write.value.kind), write.value.state.status, write.value.abortRequested ? 1 : 0, write.value.background ? 1 : 0, encodeJson(write.value)); + break; + case "submission": + await this.claimId(executor, write.value.id, "submission"); + await executor.run(`INSERT INTO ${this.schema.tables.submissions} (id, conversation_id, request_id, status, record) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET conversation_id = excluded.conversation_id, + request_id = excluded.request_id, status = excluded.status, record = excluded.record`, write.value.id, write.value.conversationId, write.value.requestId === void 0 ? null : encodeIndexedString(write.value.requestId), write.value.status, encodeJson(write.value)); + break; + case "document.create": + case "document.copy": + case "document.change": + case "document.retire": + break; + } + } + async claimId(executor, id, table) { + await executor.run(`INSERT OR IGNORE INTO ${this.schema.tables.recordIds} (id, record_type) VALUES (?, ?)`, id, table); + } + /** Resolve each copy to a base of its source's state before this batch, which may not change the source. */ + async resolveCopies(executor, actions) { + for (const [id, action] of actions) { + if (action.copy === void 0) + continue; + try { + const stored = await this.materializeDocument(executor, action.copy.id, action.copy.at); + if (stored === void 0) + throw new Error(`Fork source document ${action.copy.id} cannot be read`); + const create = action.create; + if (stored.record.scope.kind !== "conversation" || create.scope.kind !== "conversation" || stored.record.kind !== create.kind || stored.record.key !== create.key || stored.record.history !== create.history || stored.record.fork !== create.fork) { + throw new Error(`Fork source document ${action.copy.id} does not match the copied record`); + } + action.content = { kind: "base", version: stored.version, value: stored.value }; + } catch (error) { + throw new Error(`Document copy ${id} was rejected`, { cause: error }); + } + } + } + async applyDocumentActions(executor, actions, seq) { + for (const [id, action] of actions) { + const content = action.content; + let record; + if (action.create !== void 0) { + record = { + ...action.create, + createdAt: seq, + ...action.retire ? { retiredAt: seq } : {} + }; + const parts = addressParts(record); + await this.claimId(executor, id, "document"); + await executor.run(`INSERT INTO ${this.schema.tables.documents} + (id, kind, family, key_value, scope_kind, owner_id, created_at, retired_at, record) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, parts.kind, parts.family, parts.keyValue, parts.scopeKind, parts.ownerId, seq, action.retire ? seq : null, encodeJson(record)); + } else { + const row = await executor.get(`SELECT record FROM ${this.schema.tables.documents} WHERE id = ?`, id); + record = parseJson(row.record); + } + if (content !== void 0) { + if (content.kind === "base" && isCurrentOnly(record)) { + await executor.run(`DELETE FROM ${this.schema.tables.documentRevisions} WHERE document_id = ?`, id); + } + const encodedContent = content.kind === "base" ? encodeJson(content.value) : encodeJson(content.ops); + await executor.run(`INSERT INTO ${this.schema.tables.documentRevisions} (document_id, seq, kind, version, content) VALUES (?, ?, ?, ?, ?)`, id, seq, content.kind, content.version, encodedContent); + } + if (action.retire) { + if (action.create === void 0) { + record = { ...record, retiredAt: seq }; + await executor.run(`UPDATE ${this.schema.tables.documents} SET retired_at = ?, record = ? WHERE id = ?`, seq, encodeJson(record), id); + } + if (isCurrentOnly(record)) { + await executor.run(`DELETE FROM ${this.schema.tables.documentRevisions} WHERE document_id = ?`, id); + } + } + } + } + assertOpen() { + if (this.closed) + throw new Error("SqliteStorage is closed"); + } +}; + +// sqlite.ts +var op = (kind, sql = "", params = []) => Deno.core.ops.op_sql(kind, sql, params.map((p) => p instanceof Uint8Array ? Array.from(p) : typeof p === "bigint" ? Number(p) : p)); +async function openStorage(path) { + op("open", path); + let tail = Promise.resolve(); + const queue = (call) => { + const result = tail.then(call); + tail = result.then(() => { + }, () => { + }); + return result; + }; + const executor = { + exec: async (sql) => { + op("exec", sql); + }, + run: async (sql, ...params) => { + op("run", sql, params); + }, + runCounted: async (sql, ...params) => op("run", sql, params).changes, + get: async (sql, ...params) => (await executor.all(sql, ...params))[0], + all: async (sql, ...params) => op("all", sql, params).map((row) => Object.fromEntries(Object.entries(row).map(([key, value2]) => [key, Array.isArray(value2) ? new Uint8Array(value2) : value2]))) + }; + const database = Object.fromEntries(Object.entries(executor).map(([name, method]) => [name, (...args) => queue(() => method(...args))])); + database.transaction = (callback) => queue(async () => { + op("exec", "BEGIN IMMEDIATE"); + try { + const result = await callback(executor); + op("exec", "COMMIT"); + return result; + } catch (error) { + op("exec", "ROLLBACK"); + throw error; + } + }); + database.close = () => queue(async () => { + op("close"); + }); + return SqliteStorage.open(database); +} + +// credentials.ts +var Requests = defineDoc({ + kind: "sausage.credential.requests", + version: 1, + scope: "conversation", + history: "latest", + fork: "initial", + initial: () => ({ ids: [] }), + checkpointWhen: () => false +}); +var Wait = defineTask({ + name: "sausage.credential.wait", + version: 1, + initial: () => ({ phase: "wait" }), + phases: { + wait: async (task, runtime, context) => { + const request2 = Deno.core.ops.op_credential_requests().find((r) => r.id === task.input.id); + if (!request2 || request2.state === "pending") { + await runtime.sleepUntil(Date.now() + 1e3, { phase: "wait" }, context); + return; + } + const conversation = await runtime.conversation(runtime.conversationId, context); + await runtime.commit(async (tx) => { + await conversation.submit(tx, { type: "input", content: request2.message, requestId: `credential:${request2.id}` }); + return { status: "terminal", outcome: { status: "completed", result: null } }; + }, context); + } + }, + abort: (_task, runtime, context) => runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), context) +}); +var CredentialRequests = defineExtension({ name: "sausage.credentials", tasks: [Wait] }); +async function discoverRequests(harness2, context) { + for (const request2 of Deno.core.ops.op_credential_requests()) { + const conversation = await harness2.conversation(Number(request2.conversation), context); + if (!conversation) continue; + await conversation.commit(async (tx) => { + const recorded = await tx.doc(Requests, conversation.id); + if (recorded.ids.includes(request2.id)) return; + await tx.createTask(Wait, { id: request2.id }, { ownership: { kind: "conversation" }, background: true }); + recorded.ids.push(request2.id); + }, context); + } +} +async function startRequests(harness2, context) { + await discoverRequests(harness2, context); + let active = true; + const pending = (async () => { + while (active) { + await Deno.core.ops.op_credential_next(); + if (active) await discoverRequests(harness2, context); + } + })(); + return async () => { + active = false; + await pending; + }; +} + +// durable-test.ts +var models = createModels(); +var faux = fauxProvider({ tokensPerSecond: 1e5 }); +models.setProvider(faux.provider); +faux.setResponses(Array.from({ length: 8 }, () => fauxAssistantMessage([fauxText("done")]))); +var registry = createRegistry(); +registry.install(CredentialRequests); +var harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry }, BACKGROUND_CONTEXT); +var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" }, extensions: [CredentialRequests] } }); +await (await root.submit({ type: "input", content: "initial", requestId: "initial" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT); +if (globalThis.proxyOnly) { + await harness.close(BACKGROUND_CONTEXT); +} else { + await Deno.core.ops.op_file("write", "/conversation", Array.from(new TextEncoder().encode(root.id))); + const stop2 = await startRequests(harness, BACKGROUND_CONTEXT); + harness.resume(); + const watch = await root.watch(BACKGROUND_CONTEXT); + await new Promise((resolve2, reject2) => { + const check = (event) => { + if (event.kind !== "value") return; + const messages = event.value.entries.filter((e) => JSON.stringify(e).includes("Your request for")); + if (messages.length > 1) reject2(new Error("Duplicate credential message")); + if (messages.length === 1) { + const message = JSON.stringify(messages); + if (!message.includes("`github`") || !message.includes(globalThis.expectedState)) reject2(new Error("Wrong credential decision message")); + else resolve2(); + } + }; + watch.start(check); + check({ kind: "value", value: watch.value }); + }); + await harness.waitForIdle(BACKGROUND_CONTEXT); + const final = await root.watch(BACKGROUND_CONTEXT); + if (final.value.entries.filter((entry2) => entry2.kind === "pi.assistant").length !== 2) { + throw new Error("The decision must start exactly one model turn"); + } + await final.stop(); + await stop2(); + await watch.stop(); + await harness.close(BACKGROUND_CONTEXT); +} diff --git a/host/js/durable-test.ts b/host/js/durable-test.ts new file mode 100644 index 0000000..6dc82bd --- /dev/null +++ b/host/js/durable-test.ts @@ -0,0 +1,47 @@ +// Faux-model acceptance probe for credential wake-ups, including host restart. +import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'; +import { createModels } from '@earendil-works/pi-ai/models'; +import { fauxProvider, fauxAssistantMessage, fauxText } from '@earendil-works/pi-ai/providers/faux'; +import { Harness, createRegistry } from '@earendil-works/pi-durable'; +import { openStorage } from './sqlite'; +import { CredentialRequests, startRequests } from './credentials'; +const models = createModels(); +const faux = fauxProvider({ tokensPerSecond: 100000 }); +models.setProvider(faux.provider); +faux.setResponses(Array.from({ length: 8 }, () => fauxAssistantMessage([fauxText('done')]))); +const registry = createRegistry(); +registry.install(CredentialRequests); +const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry }, context); +const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, extensions: [CredentialRequests] } }); +await (await root.submit({ type: 'input', content: 'initial', requestId: 'initial' }, context)).wait(context); +if (globalThis.proxyOnly) { + await harness.close(context); +} else { + await Deno.core.ops.op_file('write', '/conversation', Array.from(new TextEncoder().encode(root.id))); + const stop = await startRequests(harness, context); + harness.resume(); + const watch = await root.watch(context); + await new Promise((resolve, reject) => { + const check = (event: any) => { + if (event.kind !== 'value') return; + const messages = event.value.entries.filter((e: any) => JSON.stringify(e).includes('Your request for')); + if (messages.length > 1) reject(new Error('Duplicate credential message')); + if (messages.length === 1) { + const message = JSON.stringify(messages); + if (!message.includes('`github`') || !message.includes(globalThis.expectedState)) reject(new Error('Wrong credential decision message')); + else resolve(); + } + }; + watch.start(check); + check({ kind: 'value', value: watch.value }); + }); + await harness.waitForIdle(context); + const final = await root.watch(context); + if (final.value.entries.filter((entry: any) => entry.kind === 'pi.assistant').length !== 2) { + throw new Error('The decision must start exactly one model turn'); + } + await final.stop(); + await stop(); + await watch.stop(); + await harness.close(context); +} diff --git a/host/js/main.ts b/host/js/main.ts index ddb7fa9..f29bb0f 100644 --- a/host/js/main.ts +++ b/host/js/main.ts @@ -6,30 +6,31 @@ import { Harness, createRegistry } from '@earendil-works/pi-durable'; import { CodingTools } from '@earendil-works/pi-durable/tools'; import { openStorage } from './sqlite'; import { environment } from './environment'; -if (!process.env.PI_PROXY_BASE_URL || !process.env.PI_PROXY_API_KEY) { - throw new Error('PI_PROXY_BASE_URL and PI_PROXY_API_KEY are required'); -} +import { CredentialRequests, startRequests } from './credentials'; +const proxy = Deno.core.ops.op_model_proxy(); +if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required'); const models = createModels(); const provider = openaiProvider(); const model = { id: 'gpt-6.1-sol', name: 'gpt-6.1-sol', provider: 'openai', api: 'openai-responses', - baseUrl: `${process.env.PI_PROXY_BASE_URL}/v1`, reasoning: true, input: ['text'], + baseUrl: `${proxy.base_url}/v1`, reasoning: true, input: ['text'], thinkingLevelMap: { off: null, minimal: null }, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096, }; models.setProvider({ ...provider, baseUrl: model.baseUrl, getModels: () => [model], auth: { apiKey: { name: 'Sausage proxy', resolve: async () => ({ - auth: { apiKey: process.env.PI_PROXY_API_KEY, baseUrl: model.baseUrl, + auth: { apiKey: proxy.api_key, baseUrl: model.baseUrl, headers: { 'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage']) } }, - source: 'PI_PROXY_API_KEY', + source: 'llm-proxy', }) } }, }); const registry = createRegistry(); registry.install(CodingTools); +registry.install(CredentialRequests); const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry, env: (target: any) => environment(target.cwd ?? '/', target.conversationId), }, context); -const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools] } }); +const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools, CredentialRequests] } }); const watch = await root.watch(context); const emitted = new Map(); let partial = ''; @@ -53,6 +54,7 @@ for (const entry of watch.value.entries) { if (entry.kind === 'pi.assistant') emitted.set(entry.id, text(entry.model?.[0])); } watch.start((event: any) => { if (event.kind === 'value') render(event.value); }); +const stopRequests = await startRequests(harness, context); harness.resume(); await harness.waitForIdle(context); while (true) { @@ -68,5 +70,6 @@ while (true) { Deno.core.ops.op_output('\n'); if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`); } +await stopRequests(); await watch.stop(); await harness.close(context); diff --git a/host/js/package.json b/host/js/package.json index 485ff4c..739fd6e 100644 --- a/host/js/package.json +++ b/host/js/package.json @@ -1,7 +1,7 @@ { "private": true, "type": "module", - "scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js" }, + "scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js" }, "dependencies": { "@earendil-works/pi-durable": "1.5.0", "@earendil-works/pi-ai": "1.5.0", diff --git a/host/src/cred/audit.rs b/host/src/cred/audit.rs new file mode 100644 index 0000000..e4afcb6 --- /dev/null +++ b/host/src/cred/audit.rs @@ -0,0 +1,75 @@ +//! Metadata-only audit ledger. Do not accept arbitrary script data. +use super::{ + error::Result, + store::{Cred, now}, + types::Caller, +}; +use rusqlite::{Connection, params}; +#[derive(Debug, serde::Serialize)] +pub struct Row { + pub id: i64, + pub time: i64, + pub event: String, + pub pid: u32, + pub program: String, + pub conversation: Option, + pub item: Option, + pub approver: Option, + pub payload_sha256: Option, +} +pub(crate) fn record( + db: &Connection, + event: &str, + caller: &Caller, + item: Option<&str>, + approver: Option<&str>, +) -> Result<()> { + db.execute( + "INSERT INTO audit(time,event,pid,conversation,item,approver,program) VALUES(?1,?2,?3,?4,?5,?6,?7)", + params![ + now(), + event, + caller.pid, + caller.conversations.first(), + item, + approver, + caller.program + ], + )?; + Ok(()) +} +impl Cred { + /// Record a completed signature. The signing pipeline supplies the caller and item. + pub fn audit_sign(&self, item: &str, caller: &Caller, payload: &[u8]) -> Result<()> { + use sha2::{Digest, Sha256}; + let mut s = self.access()?; + let tx = s.db.transaction()?; + record(&tx, "sign", caller, Some(item), None)?; + tx.execute( + "UPDATE audit SET payload_sha256=?1 WHERE id=?2", + params![hex::encode(Sha256::digest(payload)), tx.last_insert_rowid()], + )?; + tx.commit()?; + Ok(()) + } + pub fn audit_list(&self) -> Result> { + let s = self.access()?; + let mut q = s.db.prepare( + "SELECT id,time,event,pid,conversation,item,approver,payload_sha256,program FROM audit ORDER BY id", + )?; + Ok(q.query_map([], |r| { + Ok(Row { + id: r.get(0)?, + time: r.get(1)?, + event: r.get(2)?, + pid: r.get(3)?, + program: r.get(8)?, + conversation: r.get(4)?, + item: r.get(5)?, + approver: r.get(6)?, + payload_sha256: r.get(7)?, + }) + })? + .collect::>()?) + } +} diff --git a/host/src/cred/edits.rs b/host/src/cred/edits.rs new file mode 100644 index 0000000..e369474 --- /dev/null +++ b/host/src/cred/edits.rs @@ -0,0 +1,622 @@ +//! Reviewable full-configuration edits. Approval rechecks versions in its transaction. +//! Filing checks public values. Approval checks current secrets and preserves operation order. +use super::{ + audit, + error::{Error, Result}, + items::{self, Item}, + seal, + types::Caller, +}; +use rusqlite::{Connection, OptionalExtension, params}; +/// Explicit public operations. No operation accepts a secret field value. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum Operation { + CreateItem { + config: Item, + }, + GenerateEs256Item { + config: Item, + }, + SetName { + name: String, + }, + SetRules { + rules: Vec, + }, + SetFreeUse { + enabled: bool, + }, + SetValidateJs { + value: Option, + }, + SetTransformJs { + value: String, + }, + AddField { + field: items::Field, + }, + SetFieldValue { + field: String, + value: String, + }, + SetFieldType { + field: String, + field_type: super::types::FieldType, + key_type: Option, + }, + RenameField { + field: String, + name: String, + }, + RemoveField { + field: String, + }, + DeleteItem, + ItemRevert { + version: i64, + }, + GrantCreate { + grant: super::grants::Grant, + }, + GrantRevoke { + target: String, + }, +} +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct OperationRequest { + pub item: Option, + pub base_version: Option, + pub change: Vec, +} +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Edit { + pub item: String, + pub base_version: i64, + pub config: Item, +} +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Diff { + pub old: Item, + pub new: Item, + pub discards_values: bool, + #[serde(default)] + pub stale: bool, +} +fn check_secret( + db: &Connection, + master: &[u8; 32], + item: &str, + field: &items::Field, +) -> Result<()> { + let cipher: Option> = db + .query_row( + "SELECT ciphertext FROM fields WHERE item=?1 AND id=?2", + params![item, field.field_id], + |r| r.get(0), + ) + .optional()?; + if let Some(cipher) = cipher { + let value = seal::open(master, &format!("{item}|{}", field.field_id), &cipher)?; + super::types::validate( + &field.field_type, + field.key_type.as_deref(), + false, + Some(std::str::from_utf8(&value).map_err(|_| Error("invalid_value"))?), + )?; + } + Ok(()) +} +pub(crate) fn plan(db: &Connection, master: Option<&[u8; 32]>, edit: &Edit) -> Result { + let old = items::load(db, &edit.item)?; + if old.version != edit.base_version { + return Err(Error("stale")); + } + let mut new = edit.config.clone(); + new.id = old.id.clone(); + new.version = old + .version + .checked_add(1) + .ok_or(Error("version_exhausted"))?; + items::validate(&new)?; + items::unique_name(db, &new.name, &old.id)?; + for f in &mut new.fields { + if let Some(prior) = old.fields.iter().find(|p| p.field_id == f.field_id) { + if prior.public != f.public { + return Err(Error("immutable_visibility")); + } + if f.public { + f.value = f.value.clone().or_else(|| prior.value.clone()); + f.needs_value = f.value.is_none(); + } else { + f.needs_value = prior.needs_value; + if let Some(master) = master + && (f.field_type != prior.field_type || f.key_type != prior.key_type) + { + check_secret(db, master, &old.id, f)?; + } + } + } else { + if !f.field_id.is_empty() { + return Err(Error("field_missing")); + } + f.needs_value = f.value.is_none(); + } + } + items::validate(&new)?; + let discards_values = old + .fields + .iter() + .any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id)); + Ok(Diff { + old, + new, + discards_values, + stale: false, + }) +} +pub(crate) fn apply( + db: &Connection, + master: &[u8; 32], + edit: &Edit, + caller: &Caller, + approver: Option<&str>, +) -> Result<()> { + let mut diff = plan(db, Some(master), edit)?; + db.execute( + "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", + params![edit.item, serde_json::to_string(&diff.old)?], + )?; + for old in &diff.old.fields { + if !diff.new.fields.iter().any(|f| f.field_id == old.field_id) { + db.execute( + "DELETE FROM fields WHERE item=?1 AND id=?2", + params![edit.item, old.field_id], + )?; + } + } + for f in &mut diff.new.fields { + if f.field_id.is_empty() { + f.field_id = seal::identity(); + } + } + items::save(db, &diff.new)?; + audit::record(db, "item.edit", caller, Some(&edit.item), approver) +} +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Revert { + pub item: String, + pub base_version: i64, + pub version: i64, +} +pub(crate) fn revert_revision(db: &Connection, item: &str) -> Result { + if let Ok(item) = items::load(db, item) { + return Ok(item.version); + } + let latest: Option = db.query_row( + "SELECT max(json_extract(config,'$.version')) FROM versions WHERE item=?", + [item], + |r| r.get(0), + )?; + latest + .ok_or(Error("item_deleted"))? + .checked_add(1) + .ok_or(Error("version_exhausted")) +} +pub(crate) fn plan_revert(db: &Connection, master: Option<&[u8; 32]>, r: &Revert) -> Result { + if revert_revision(db, &r.item)? != r.base_version { + return Err(Error("stale")); + } + let saved:String=db.query_row("SELECT config FROM versions WHERE item=?1 AND json_extract(config,'$.version')=?2 ORDER BY rowid DESC LIMIT 1",params![r.item,r.version],|row|row.get(0))?; + let mut new: Item = serde_json::from_str(&saved)?; + let current = items::load(db, &r.item).ok(); + for f in &mut new.fields { + let prior = current + .as_ref() + .and_then(|i| i.fields.iter().find(|p| p.field_id == f.field_id)); + if let Some(prior) = prior { + if prior.public != f.public { + return Err(Error("immutable_visibility")); + } + f.needs_value = prior.needs_value; + if f.public { + f.value = prior.value.clone(); + } else if let Some(master) = master { + check_secret(db, master, &r.item, f)?; + } + } else { + f.needs_value = f.value.is_none(); + } + } + items::validate(&new)?; + items::unique_name(db, &new.name, &new.id)?; + new.version = r + .base_version + .checked_add(1) + .ok_or(Error("version_exhausted"))?; + let old = current.unwrap_or_else(|| { + let mut old = new.clone(); + old.version = r.base_version; + old.fields.clear(); + old + }); + let discards_values = old + .fields + .iter() + .any(|f| !f.needs_value && !new.fields.iter().any(|n| n.field_id == f.field_id)); + Ok(Diff { + old, + new, + discards_values, + stale: false, + }) +} +pub(crate) fn apply_revert( + db: &Connection, + master: &[u8; 32], + r: &Revert, + caller: &Caller, + approver: Option<&str>, +) -> Result<()> { + let diff = plan_revert(db, Some(master), r)?; + if items::load(db, &r.item).is_ok() { + db.execute( + "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", + params![r.item, serde_json::to_string(&diff.old)?], + )?; + items::save(db, &diff.new)?; + } else { + db.execute( + "INSERT INTO items VALUES(?1,?2,?3)", + params![r.item, diff.new.name, serde_json::to_string(&diff.new)?], + )?; + } + for f in &diff.old.fields { + if !diff.new.fields.iter().any(|n| n.field_id == f.field_id) { + db.execute( + "DELETE FROM fields WHERE item=?1 AND id=?2", + params![r.item, f.field_id], + )?; + } + } + audit::record(db, "item.revert", caller, Some(&r.item), approver) +} +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Delete { + pub item: String, + pub base_version: i64, +} +pub(crate) fn apply_delete( + db: &Connection, + delete: &Delete, + caller: &Caller, + approver: Option<&str>, +) -> Result<()> { + let item = items::load(db, &delete.item)?; + if item.version != delete.base_version { + return Err(Error("stale")); + } + db.execute( + "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", + params![item.id, serde_json::to_string(&item)?], + )?; + db.execute("DELETE FROM items WHERE id=?", [&item.id])?; + db.execute("UPDATE grants SET revoked=1 WHERE EXISTS(SELECT 1 FROM json_each(grants.items) WHERE value=?)", [&item.id])?; + audit::record(db, "item.delete", caller, Some(&item.id), approver) +} +pub(crate) fn apply_changes( + mut config: Item, + change: &[Operation], + start: usize, + secrets: Option<(&Connection, &[u8; 32])>, +) -> Result { + for operation in &change[start..] { + let field = |id: &str| { + if id.is_empty() { + return Err(Error("field_missing")); + } + config + .fields + .iter() + .position(|f| f.field_id == id) + .ok_or(Error("field_missing")) + }; + match operation { + Operation::SetName { name } => config.name = name.clone(), + Operation::SetRules { rules } => config.rules = rules.clone(), + Operation::SetFreeUse { enabled } => config.free_use = *enabled, + Operation::SetValidateJs { value } => config.validate_js = value.clone(), + Operation::SetTransformJs { value } => config.transform_js = value.clone(), + Operation::AddField { field } => { + let mut field = field.clone(); + if !field.field_id.is_empty() { + return Err(Error("invalid_params")); + } + if !field.public && field.value.is_some() { + return Err(Error("secret_field")); + } + super::types::validate( + &field.field_type, + field.key_type.as_deref(), + field.public, + field.value.as_deref(), + )?; + field.needs_value = field.value.is_none(); + config.fields.push(field); + } + Operation::SetFieldValue { field: id, value } => { + let n = field(id)?; + if !config.fields[n].public { + return Err(Error("secret_field")); + } + super::types::validate( + &config.fields[n].field_type, + config.fields[n].key_type.as_deref(), + true, + Some(value), + )?; + config.fields[n].value = Some(value.clone()); + config.fields[n].needs_value = false; + } + Operation::SetFieldType { + field: id, + field_type, + key_type, + } => { + let n = field(id)?; + config.fields[n].field_type = field_type.clone(); + config.fields[n].key_type = key_type.clone(); + let f = &config.fields[n]; + super::types::validate( + &f.field_type, + f.key_type.as_deref(), + f.public, + f.value.as_deref(), + )?; + if !f.public + && let Some((db, master)) = secrets + { + check_secret(db, master, &config.id, f)?; + } + } + Operation::RenameField { field: id, name } => { + let n = field(id)?; + config.fields[n].name = name.clone(); + } + Operation::RemoveField { field: id } => { + let n = field(id)?; + config.fields.remove(n); + } + _ => return Err(Error("invalid_params")), + } + } + Ok(config) +} +impl super::Cred { + pub fn file_operation_request( + &self, + caller: &Caller, + key: &str, + request: OperationRequest, + ) -> Result { + if request.change.is_empty() { + return Err(Error("invalid_params")); + } + if request.change.len() == 1 { + match &request.change[0] { + Operation::DeleteItem => { + return self.file_delete_request( + caller, + key, + Delete { + item: request.item.clone().ok_or(Error("invalid_params"))?, + base_version: request.base_version.ok_or(Error("invalid_params"))?, + }, + ); + } + Operation::ItemRevert { version } => { + return self.file_revert_request( + caller, + key, + request.item.as_deref().ok_or(Error("invalid_params"))?, + request.base_version.ok_or(Error("invalid_params"))?, + *version, + ); + } + Operation::GrantCreate { grant } => { + let mut grant = grant.clone(); + grant.id.clear(); + grant.uses = 0; + grant.revoked = false; + grant.items.sort(); + grant.items.dedup(); + let payload = serde_json::json!({"grant_create":grant}); + if let Some(id) = self.replay(caller, key, &payload)? { + return Ok(id); + } + super::grants::validate(&self.access()?.db, &grant)?; + return self.file_payload( + caller, + key, + serde_json::json!({"grant_create":grant}), + ); + } + Operation::GrantRevoke { target } => { + self.grant_status(target)?; + return self.file_payload( + caller, + key, + serde_json::json!({"grant_revoke":target}), + ); + } + _ => {} + } + } + let intent = serde_json::json!({"requested":request}); + if let Some(id) = self.replay(caller, key, &intent)? { + return Ok(id); + } + let mut create = false; + let mut generate = false; + let config = if let Some(id) = &request.item { + self.item_get(id)? + } else { + create = true; + match &request.change[0] { + Operation::CreateItem { config } => config.clone(), + Operation::GenerateEs256Item { config } => { + generate = true; + config.clone() + } + _ => return Err(Error("invalid_params")), + } + }; + if create { + items::validate(&config)?; + } + let config = apply_changes(config, &request.change, usize::from(create), None)?; + if create { + if request.base_version.is_some() { + return Err(Error("invalid_params")); + } + items::validate(&config)?; + if generate + && !config.fields.iter().any(|f| { + !f.public + && f.field_type == super::types::FieldType::SshPrivateKey + && f.key_type.as_deref() == Some("es256") + }) + { + return Err(Error("invalid_params")); + } + items::unique_name(&self.access()?.db, &config.name, "")?; + return self.file_payload( + caller, + key, + serde_json::json!({"create":config,"generate_es256":generate,"requested":request}), + ); + } + let edit = Edit { + item: request.item.clone().ok_or(Error("invalid_params"))?, + base_version: request.base_version.ok_or(Error("invalid_params"))?, + config, + }; + let diff = { + let s = self.access()?; + plan(&s.db, None, &edit)? + }; + self.file_payload( + caller, + key, + serde_json::json!({"edit":edit,"diff":diff,"requested":request}), + ) + } + pub fn file_delete_request( + &self, + caller: &Caller, + key: &str, + delete: Delete, + ) -> Result { + if let Some(id) = self.replay(caller, key, &serde_json::json!({"delete":delete}))? { + return Ok(id); + } + let item = self.item_get(&delete.item)?; + if item.version != delete.base_version { + return Err(Error("stale")); + } + self.file_payload(caller, key, serde_json::json!({"delete":delete,"old":item})) + } + /// Revert public configuration. Removed secrets remain absent. + pub fn file_revert_request( + &self, + caller: &Caller, + key: &str, + item: &str, + base_version: i64, + version: i64, + ) -> Result { + let revert = Revert { + item: item.into(), + base_version, + version, + }; + if let Some(id) = self.replay(caller, key, &serde_json::json!({"revert":revert}))? { + return Ok(id); + } + let diff = { + let s = self.access()?; + plan_revert(&s.db, None, &revert)? + }; + self.file_payload( + caller, + key, + serde_json::json!({"revert":revert,"diff":diff}), + ) + } + pub fn file_edit_request(&self, caller: &Caller, key: &str, edit: Edit) -> Result { + if let Some(id) = self.replay(caller, key, &serde_json::json!({"edit":edit}))? { + return Ok(id); + } + let diff = { + let s = self.access()?; + plan(&s.db, None, &edit)? + }; + self.file_payload(caller, key, serde_json::json!({"edit":edit,"diff":diff})) + } + /// Replace a pending edit's base version after the UI reviews the current item. + pub fn resolve_stale(&self, id: &str, base_version: i64) -> Result<()> { + let mut s = self.access()?; + let tx = + s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let (payload, state): (String, String) = + tx.query_row("SELECT payload,state FROM requests WHERE id=?", [id], |r| { + Ok((r.get(0)?, r.get(1)?)) + })?; + if state != "pending" { + return Err(Error("already_decided")); + } + let mut payload: serde_json::Value = serde_json::from_str(&payload)?; + if let Some(revert) = payload.get("revert") { + let mut revert: Revert = serde_json::from_value(revert.clone())?; + revert.base_version = base_version; + let diff = plan_revert(&tx, None, &revert)?; + payload["revert"] = serde_json::to_value(revert)?; + payload["diff"] = serde_json::to_value(diff)?; + } else { + let mut edit: Edit = + serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?; + edit.base_version = base_version; + if let Some(requested) = payload.get("requested") { + let requested: OperationRequest = serde_json::from_value(requested.clone())?; + edit.config = + apply_changes(items::load(&tx, &edit.item)?, &requested.change, 0, None)?; + } + let diff = plan(&tx, None, &edit)?; + payload["edit"] = serde_json::to_value(edit)?; + payload["diff"] = serde_json::to_value(diff)?; + } + tx.execute( + "UPDATE requests SET payload=?2 WHERE id=?1", + params![id, serde_json::to_string(&payload)?], + )?; + tx.commit()?; + Ok(()) + } + pub fn edit_diff(&self, id: &str) -> Result { + let payload = self.request_diff(id)?; + let mut diff: Diff = + serde_json::from_value(payload.get("diff").ok_or(Error("not_edit"))?.clone())?; + if let Some(revert) = payload.get("revert") { + let revert: Revert = serde_json::from_value(revert.clone())?; + diff.stale = revert_revision(&self.access()?.db, &revert.item) + .map_or(true, |v| v != revert.base_version); + } else { + let edit: Edit = + serde_json::from_value(payload.get("edit").ok_or(Error("not_edit"))?.clone())?; + diff.stale = self + .item_get(&edit.item) + .map_or(true, |i| i.version != edit.base_version); + } + Ok(diff) + } +} diff --git a/host/src/cred/entry.rs b/host/src/cred/entry.rs new file mode 100644 index 0000000..59a24dc --- /dev/null +++ b/host/src/cred/entry.rs @@ -0,0 +1,86 @@ +//! Browser secret entry: ECDH P-256, HKDF-SHA256, AES-256-GCM. +//! JSON envelopes contain byte arrays: `ephemeral_public_key` (65-byte SEC1 +//! uncompressed point), `iv` (12 bytes), and `ciphertext` (includes the GCM tag). +//! HKDF uses an empty salt and info `sausage/secret-entry/v1`. There is no AAD. +//! The binding hash is lowercase hex SHA-256(ephemeral_public_key || iv || ciphertext). +//! The public key uses the same SEC1 format for WebCrypto's `raw` import. +use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; +use anyhow::{Result, bail}; +use hkdf::Hkdf; +use p256::{PublicKey, SecretKey, ecdh::diffie_hellman, elliptic_curve::sec1::ToEncodedPoint}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{fs::OpenOptions, io::Write, os::unix::fs::OpenOptionsExt, path::Path}; + +#[derive(Serialize, Deserialize)] +pub struct Envelope { + pub ephemeral_public_key: Vec, + pub iv: [u8; 12], + pub ciphertext: Vec, +} + +impl Envelope { + pub fn sha256(&self) -> String { + let mut hash = Sha256::new(); + hash.update(&self.ephemeral_public_key); + hash.update(self.iv); + hash.update(&self.ciphertext); + format!("{:x}", hash.finalize()) + } +} + +pub struct EntryKey(SecretKey); + +impl EntryKey { + /// The caller supplies a path in the host folder, never in the world. + pub fn open(path: &Path) -> Result { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let key = SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path)?; + file.write_all(&key.to_bytes())?; + file.sync_all()?; + return Ok(Self(key)); + } + Err(e) => return Err(e.into()), + }; + Ok(Self( + SecretKey::from_slice(&bytes).map_err(|_| anyhow::anyhow!("invalid entry key"))?, + )) + } + + pub fn public_key(&self) -> Vec { + self.0 + .public_key() + .to_encoded_point(false) + .as_bytes() + .to_vec() + } + + /// The caller must first verify a SecretEntry assertion and compare its + /// item, field, and envelope hash. This method does not authorize a write. + pub fn decrypt(&self, envelope: &Envelope) -> Result> { + if envelope.ephemeral_public_key.len() != 65 || envelope.ephemeral_public_key[0] != 4 { + bail!("invalid secret envelope"); + } + let peer = PublicKey::from_sec1_bytes(&envelope.ephemeral_public_key) + .map_err(|_| anyhow::anyhow!("invalid secret envelope"))?; + let shared = diffie_hellman(self.0.to_nonzero_scalar(), peer.as_affine()); + let mut key = [0u8; 32]; + Hkdf::::new(None, shared.raw_secret_bytes()) + .expand(b"sausage/secret-entry/v1", &mut key) + .map_err(|_| anyhow::anyhow!("invalid secret envelope"))?; + Aes256Gcm::new_from_slice(&key) + .expect("AES key length") + .decrypt( + Nonce::from_slice(&envelope.iv), + envelope.ciphertext.as_ref(), + ) + .map_err(|_| anyhow::anyhow!("invalid secret envelope")) + } +} diff --git a/host/src/cred/error.rs b/host/src/cred/error.rs new file mode 100644 index 0000000..b98b89c --- /dev/null +++ b/host/src/cred/error.rs @@ -0,0 +1,20 @@ +//! Stable failures. Never include input values or script exception messages. +#[derive(Debug, thiserror::Error)] +#[error("credential operation failed: {0}")] +pub struct Error(pub &'static str); +pub type Result = std::result::Result; +impl From for Error { + fn from(_: rusqlite::Error) -> Self { + Self("storage") + } +} +impl From for Error { + fn from(_: std::io::Error) -> Self { + Self("io") + } +} +impl From for Error { + fn from(_: serde_json::Error) -> Self { + Self("json") + } +} diff --git a/host/src/cred/grants.rs b/host/src/cred/grants.rs new file mode 100644 index 0000000..3d04654 --- /dev/null +++ b/host/src/cred/grants.rs @@ -0,0 +1,170 @@ +//! Conversation-subtree grant ledger. Reservations use an immediate transaction. +use super::{ + audit, + error::{Error, Result}, + seal, + store::{Cred, now}, + types::{Caller, Subject}, +}; +use rusqlite::{Connection, TransactionBehavior, params}; +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Grant { + #[serde(default)] + pub id: String, + pub items: Vec, + pub subject: Subject, + pub exp: Option, + pub max_uses: Option, + #[serde(default)] + pub uses: u64, + #[serde(default)] + pub revoked: bool, + pub validator: Option, + pub note: Option, +} +pub(crate) fn list(db: &Connection) -> Result> { + let mut q=db.prepare("SELECT id,items,subject,exp,max_uses,uses,revoked,validator,note FROM grants ORDER BY rowid")?; + Ok(q.query_map([], |r| { + Ok(Grant { + id: r.get(0)?, + items: serde_json::from_str(&r.get::<_, String>(1)?) + .map_err(|_| rusqlite::Error::InvalidQuery)?, + subject: serde_json::from_str(&r.get::<_, String>(2)?) + .map_err(|_| rusqlite::Error::InvalidQuery)?, + exp: r.get(3)?, + max_uses: r + .get::<_, Option>(4)? + .map(u64::try_from) + .transpose() + .map_err(|_| rusqlite::Error::InvalidQuery)?, + uses: u64::try_from(r.get::<_, i64>(5)?).map_err(|_| rusqlite::Error::InvalidQuery)?, + revoked: r.get(6)?, + validator: r.get(7)?, + note: r.get(8)?, + }) + })? + .collect::>()?) +} +pub(crate) fn insert( + db: &Connection, + grant: &Grant, + caller: &Caller, + approver: Option<&str>, +) -> Result<()> { + validate(db, grant)?; + db.execute( + "INSERT INTO grants VALUES(?1,?2,?3,?4,?5,0,0,?6,?7)", + params![ + grant.id, + serde_json::to_string(&grant.items)?, + serde_json::to_string(&grant.subject)?, + grant.exp, + grant.max_uses.map(|m| m as i64), + grant.validator, + grant.note + ], + )?; + audit::record( + db, + "grant.create", + caller, + grant.items.first().map(String::as_str), + approver, + ) +} +pub(crate) fn validate(db: &Connection, grant: &Grant) -> Result<()> { + if grant.max_uses == Some(0) || grant.max_uses.is_some_and(|m| m > i64::MAX as u64) { + return Err(Error("invalid_uses")); + } + if !grant.subject.valid() { + return Err(Error("invalid_subject")); + } + if grant.items.is_empty() { + return Err(Error("invalid_items")); + } + for item in &grant.items { + super::items::load(db, item)?; + } + Ok(()) +} +impl Cred { + pub fn grant_create( + &self, + mut grant: Grant, + caller: &Caller, + approver: Option<&str>, + ) -> Result { + grant.id = seal::identity(); + grant.uses = 0; + grant.revoked = false; + grant.items.sort(); + grant.items.dedup(); + let mut s = self.access()?; + let tx = + s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + insert(&tx, &grant, caller, approver)?; + tx.commit()?; + Ok(grant) + } + pub fn grant_list(&self) -> Result> { + list(&self.access()?.db) + } + pub fn grant_status(&self, id: &str) -> Result { + self.grant_list()? + .into_iter() + .find(|g| g.id == id) + .ok_or(Error("grant_missing")) + } + pub fn revoke_subjects( + &self, + subjects: &[String], + caller: &Caller, + approver: Option<&str>, + ) -> Result<()> { + let mut s = self.access()?; + let tx = s.db.transaction()?; + for subject in subjects { + tx.execute( + "UPDATE grants SET revoked=1 WHERE subject=?", + [serde_json::to_string(&Subject::Conversation( + subject.clone(), + ))?], + )?; + } + audit::record(&tx, "grant.revoke", caller, None, approver)?; + tx.commit()?; + Ok(()) + } + pub(crate) fn reserve( + &self, + item: &super::items::Item, + caller: &Caller, + ) -> Result> { + let mut s = self.access()?; + let master = s.master.clone(); + let tx = + s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let grant = list(&tx)?.into_iter().find(|g| { + g.items.iter().any(|i| i == &item.id) + && g.subject.covers(caller) + && !g.revoked + && g.exp.is_none_or(|e| e > now()) + && g.max_uses.is_none_or(|m| g.uses < m) + }); + if let Some(mut g) = grant { + if super::items::load(&tx, &item.id)?.version != item.version { + return Err(Error("stale")); + } + let credential = super::items::credential(&tx, &master, item, true)?; + let changed=tx.execute("UPDATE grants SET uses=uses+1 WHERE id=?1 AND revoked=0 AND (exp IS NULL OR exp>?2) AND (max_uses IS NULL OR uses bool { + item.rules.iter().any(|r| { + super::items::rule_matches( + r, + p.uri.scheme_str().unwrap_or(""), + p.uri.host().unwrap_or(""), + p.uri.path(), + p.method.as_str(), + ) + }) +} +fn request(p: &Parts) -> Result { + let mut headers = Vec::new(); + for (name, value) in &p.headers { + headers.push(json!({"name":name.as_str(),"value":value.to_str().map_err(|_|Error("invalid_headers"))?})); + } + Ok(json!({"method":p.method.as_str(),"url":p.uri.to_string(),"headers":headers})) +} +fn evaluate(script: &dyn Script, source: &str, context: &Value) -> Result { + let input = serde_json::to_string(context)?; + let result = script + .evaluate(source, &input, None, Limits::default()) + .map_err(|_| Error("script"))?; + serde_json::from_str(&result).map_err(|_| Error("script")) +} +impl Cred { + pub fn inject( + &self, + mut parts: Parts, + caller: &Caller, + script: &dyn Script, + ) -> Result { + let candidates: Vec<_> = self + .item_list()? + .into_iter() + .filter(|i| matches(i, &parts)) + .collect(); + if candidates.len() > 1 { + return Err(Error("ambiguous_item")); + } + let Some(item) = candidates.into_iter().next() else { + return Ok(Outcome::Forward(parts)); + }; + let original_request = request(&parts)?; + if item + .fields + .iter() + .any(|f| f.field_type == super::types::FieldType::SshPrivateKey) + { + return Err(Error("ssh_item")); + } + let prepared = if item.free_use { + Some((None, self.credential(&item, true)?)) + } else { + self.reserve(&item, caller)? + .map(|(grant, credential)| (Some(grant), credential)) + }; + let Some((grant, secret_credential)) = prepared else { + let request = self.file_request( + caller, + &format!("inject:{}", item.id), + GrantRequest { + items: vec![item.id.clone()], + duration_secs: None, + max_uses: None, + validator: None, + }, + )?; + return Ok(Outcome::Denied { + item: item.name, + request, + }); + }; + let result = (|| { + let public = self.credential(&item, false)?; + let ctx = json!({"request":original_request,"credential":public,"item":{"id":item.id,"name":item.name},"grant":grant.as_ref().map(|g|json!({"conversation":match &g.subject {super::types::Subject::Conversation(id)=>Some(id),super::types::Subject::Program(_)=>None},"subject":g.subject,"uses_left":g.max_uses.map(|m|m-g.uses),"exp":g.exp}))}); + for source in [ + item.validate_js.as_deref(), + grant.as_ref().and_then(|g| g.validator.as_deref()), + ] + .into_iter() + .flatten() + { + let value = evaluate(script, source, &ctx)?; + let allow = value + .as_bool() + .or_else(|| value.get("allow").and_then(Value::as_bool)) + .ok_or(Error("script"))?; + if !allow { + return Err(Error("validator_denied")); + } + } + if !item.transform_js.is_empty() { + let mut ctx = json!({"request":original_request,"credential":secret_credential}); + let url = + url::Url::parse(&parts.uri.to_string()).map_err(|_| Error("invalid_url"))?; + let mut base = url.clone(); + base.set_query(None); + base.set_fragment(None); + ctx["url"] = json!({"base":base.as_str(),"query":url.query_pairs().collect::>(),"fragment":url.fragment().map(|f|format!("#{f}")).unwrap_or_default()}); + let output = evaluate(script, &item.transform_js, &ctx)?; + let object = output.as_object().ok_or(Error("transform"))?; + if object + .keys() + .any(|k| k != "url" && k != "headers" && k != "method") + || output + .get("method") + .is_some_and(|m| m.as_str() != Some(parts.method.as_str())) + { + return Err(Error("transform")); + } + if let Some(url) = output.get("url") { + parts.uri = url + .as_str() + .ok_or(Error("transform"))? + .parse() + .map_err(|_| Error("transform"))?; + } + if let Some(headers) = output.get("headers") { + let mut new = http::HeaderMap::new(); + for header in headers.as_array().ok_or(Error("transform"))? { + let name = header + .get("name") + .and_then(Value::as_str) + .ok_or(Error("transform"))?; + let value = header + .get("value") + .and_then(Value::as_str) + .ok_or(Error("transform"))?; + new.append( + http::header::HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| Error("transform"))?, + http::HeaderValue::from_str(value).map_err(|_| Error("transform"))?, + ); + } + parts.headers = new; + } + } + if !matches(&item, &parts) { + return Err(Error("rule_recheck")); + } + Ok(()) + })(); + super::audit::record( + &self.access()?.db, + if result.is_ok() { + "fetch.use" + } else { + "fetch.denied" + }, + caller, + Some(&item.id), + None, + )?; + result?; + Ok(Outcome::Forward(parts)) + } +} diff --git a/host/src/cred/items.rs b/host/src/cred/items.rs new file mode 100644 index 0000000..4ba8579 --- /dev/null +++ b/host/src/cred/items.rs @@ -0,0 +1,352 @@ +//! Public item snapshots and host-only field entry. Snapshots never contain secret values. +use super::{ + error::{Error, Result}, + seal, + store::Cred, + types::{Caller, FieldType}, +}; +use rusqlite::{Connection, OptionalExtension, params}; +use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Rule { + pub scheme: String, + pub hosts: Vec, + pub path_prefixes: Vec, + pub methods: Option>, +} +pub fn rule_matches(r: &Rule, scheme: &str, host: &str, path: &str, method: &str) -> bool { + r.scheme.eq_ignore_ascii_case(scheme) + && r.hosts.iter().any(|p| { + if let Some(s) = p.strip_prefix("*.") { + host.to_ascii_lowercase() + .strip_suffix(&format!(".{}", s.to_ascii_lowercase())) + .is_some_and(|p| !p.is_empty()) + } else { + p.eq_ignore_ascii_case(host) + } + }) + && r.path_prefixes.iter().any(|p| path.starts_with(p)) + && r.methods + .as_ref() + .is_none_or(|ms| ms.iter().any(|m| m.eq_ignore_ascii_case(method))) +} +#[derive(Clone, Serialize, Deserialize)] +pub struct Field { + #[serde(default)] + pub field_id: String, + pub name: String, + #[serde(default)] + pub public: bool, + pub value: Option, + #[serde(default)] + pub needs_value: bool, + pub field_type: FieldType, + pub key_type: Option, +} +impl std::fmt::Debug for Field { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Field") + .field("field_id", &self.field_id) + .field("name", &self.name) + .field("public", &self.public) + .field("needs_value", &self.needs_value) + .field("field_type", &self.field_type) + .field("key_type", &self.key_type) + .field( + "value", + &if self.public { + self.value.as_deref() + } else { + None + }, + ) + .finish() + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Item { + #[serde(default)] + pub id: String, + pub name: String, + #[serde(default)] + pub version: i64, + #[serde(default)] + pub free_use: bool, + pub rules: Vec, + pub validate_js: Option, + pub transform_js: String, + pub fields: Vec, +} +pub(crate) fn load(db: &Connection, id: &str) -> Result { + let json: Option = db + .query_row("SELECT config FROM items WHERE id=?", [id], |r| r.get(0)) + .optional()?; + Ok(serde_json::from_str(&json.ok_or(Error("item_deleted"))?)?) +} +pub(crate) fn save(db: &Connection, item: &Item) -> Result<()> { + db.execute( + "UPDATE items SET name=?2,config=?3 WHERE id=?1", + params![item.id, item.name, serde_json::to_string(item)?], + )?; + Ok(()) +} +pub(crate) fn validate(item: &Item) -> Result<()> { + if item.name.trim().is_empty() { + return Err(Error("invalid_params")); + } + let mut names = std::collections::HashSet::new(); + let mut ids = std::collections::HashSet::new(); + for f in &item.fields { + if !f.public && f.value.is_some() { + return Err(Error("secret_field")); + } + super::types::validate( + &f.field_type, + f.key_type.as_deref(), + f.public, + f.value.as_deref(), + )?; + if f.name.trim().is_empty() + || !names.insert(&f.name) + || (!f.field_id.is_empty() && !ids.insert(&f.field_id)) + { + return Err(Error("invalid_params")); + } + } + if item + .fields + .iter() + .filter(|f| f.field_type == FieldType::Oauth2RefreshToken) + .count() + > 1 + { + return Err(Error("invalid_params")); + } + for r in &item.rules { + if !matches!( + r.scheme.to_ascii_lowercase().as_str(), + "http" | "https" | "ssh" + ) || r.path_prefixes.iter().any(|p| !p.starts_with('/')) + || r.methods.as_ref().is_some_and(|ms| { + ms.iter().any(|m| { + m.is_empty() || !m.bytes().all(|b| b.is_ascii_alphabetic() || b == b'-') + }) + }) + || r.hosts.iter().any(|p| { + let h = p.strip_prefix("*.").unwrap_or(p); + h.is_empty() + || h.starts_with('.') + || h.ends_with('.') + || h.contains("..") + || h.bytes().any(|b| { + b.is_ascii_whitespace() || matches!(b, b'*' | b'/' | b'?' | b'#' | b'@') + }) + || (h.contains(':') && h.parse::().is_err()) + }) + { + return Err(Error("invalid_params")); + } + } + Ok(()) +} +pub(crate) fn unique_name(db: &Connection, name: &str, except: &str) -> Result<()> { + let exists: bool = db.query_row( + "SELECT EXISTS(SELECT 1 FROM items WHERE name=?1 AND id<>?2)", + params![name, except], + |r| r.get(0), + )?; + if exists { + Err(Error("name_conflict")) + } else { + Ok(()) + } +} +pub(crate) fn insert_new( + db: &Connection, + mut item: Item, + caller: &Caller, + approver: Option<&str>, +) -> Result { + validate(&item)?; + item.id = seal::identity(); + item.version = 1; + for field in &mut item.fields { + field.field_id = seal::identity(); + field.needs_value = field.value.is_none(); + } + unique_name(db, &item.name, "")?; + db.execute( + "INSERT INTO items VALUES(?1,?2,?3)", + params![item.id, item.name, serde_json::to_string(&item)?], + )?; + super::audit::record(db, "item.create", caller, Some(&item.id), approver)?; + Ok(item) +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ItemVersion { + pub created: i64, + pub config: Item, +} +/// A secret-entry value with optional replacement metadata. Debug never includes the value. +pub struct FieldInput<'a> { + pub value: &'a str, + pub field_type: Option, + pub key_type: Option, +} +impl std::fmt::Debug for FieldInput<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FieldInput") + .field("field_type", &self.field_type) + .field("key_type", &self.key_type) + .finish_non_exhaustive() + } +} +impl Cred { + /// Trusted host mutation. The route layer must verify a passkey before this call. + pub fn create_item(&self, item: Item, caller: &Caller, approver: &str) -> Result { + let mut s = self.access()?; + let tx = + s.db.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let item = insert_new(&tx, item, caller, Some(approver))?; + tx.commit()?; + Ok(item) + } + pub fn item_get(&self, id: &str) -> Result { + load(&self.access()?.db, id) + } + pub fn item_list(&self) -> Result> { + let s = self.access()?; + let mut q = s.db.prepare("SELECT config FROM items ORDER BY name")?; + q.query_map([], |r| r.get::<_, String>(0))? + .map(|r| Ok(serde_json::from_str(&r?)?)) + .collect() + } + pub fn item_versions(&self, id: &str) -> Result> { + let s = self.access()?; + let mut q = + s.db.prepare("SELECT created,config FROM versions WHERE item=? ORDER BY rowid")?; + q.query_map([id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))? + .map(|r| { + let (created, config) = r?; + Ok(ItemVersion { + created, + config: serde_json::from_str(&config)?, + }) + }) + .collect() + } + /// Trusted secret entry core. Never put the value into a public snapshot. + pub fn set_field( + &self, + item: &str, + field: &str, + value: &str, + caller: &Caller, + approver: &str, + ) -> Result<()> { + self.set_field_input( + item, + field, + &FieldInput { + value, + field_type: None, + key_type: None, + }, + caller, + approver, + ) + } + pub fn set_field_input( + &self, + item: &str, + field: &str, + input: &FieldInput<'_>, + caller: &Caller, + approver: &str, + ) -> Result<()> { + let value = input.value; + let mut s = self.access()?; + let key = s.master.clone(); + let tx = s.db.transaction()?; + let mut config = load(&tx, item)?; + let index = config + .fields + .iter() + .position(|f| f.field_id == field) + .ok_or(Error("field_missing"))?; + let f = &config.fields[index]; + let kind = input.field_type.as_ref().unwrap_or(&f.field_type).clone(); + let key_type = if input.field_type.is_some() { + input.key_type.clone() + } else { + input.key_type.clone().or_else(|| f.key_type.clone()) + }; + super::types::validate(&kind, key_type.as_deref(), f.public, Some(value))?; + let previous = serde_json::to_string(&config)?; + if config.fields[index].public { + config.fields[index].value = Some(value.to_owned()); + } else { + let encrypted = seal::seal(&key, &format!("{item}|{field}"), value.as_bytes())?; + tx.execute( + "INSERT OR REPLACE INTO fields VALUES(?1,?2,?3)", + params![item, field, encrypted], + )?; + } + config.fields[index].needs_value = false; + config.fields[index].field_type = kind; + config.fields[index].key_type = key_type; + validate(&config)?; + tx.execute( + "INSERT INTO versions(item,created,config) VALUES(?1,unixepoch(),?2)", + params![item, previous], + )?; + config.version = config + .version + .checked_add(1) + .ok_or(Error("version_exhausted"))?; + save(&tx, &config)?; + super::audit::record(&tx, "field.set", caller, Some(item), Some(approver))?; + tx.commit()?; + Ok(()) + } + pub(crate) fn credential(&self, item: &Item, secrets: bool) -> Result { + let s = self.access()?; + if secrets && load(&s.db, &item.id)?.version != item.version { + return Err(Error("stale")); + } + credential(&s.db, &s.master, item, secrets) + } +} +pub(crate) fn credential( + db: &Connection, + master: &[u8; 32], + item: &Item, + secrets: bool, +) -> Result { + if secrets && item.fields.iter().any(|f| f.needs_value) { + return Err(Error("needs_value")); + } + let mut values = serde_json::Map::new(); + for f in &item.fields { + if f.public { + if let Some(v) = &f.value { + values.insert(f.name.clone(), v.clone().into()); + } + } else if secrets { + let cipher: Option> = db + .query_row( + "SELECT ciphertext FROM fields WHERE item=?1 AND id=?2", + params![item.id, f.field_id], + |r| r.get(0), + ) + .optional()?; + let cipher = cipher.ok_or(Error("needs_value"))?; + { + let bytes = seal::open(master, &format!("{}|{}", item.id, f.field_id), &cipher)?; + let value = std::str::from_utf8(&bytes).map_err(|_| Error("field_invalid"))?; + values.insert(f.name.clone(), value.into()); + } + } + } + Ok(values.into()) +} diff --git a/host/src/cred/keys.rs b/host/src/cred/keys.rs new file mode 100644 index 0000000..78dee7e --- /dev/null +++ b/host/src/cred/keys.rs @@ -0,0 +1,305 @@ +//! Key readers and signature/public-key codecs. No grant or storage policy here. +use super::sign::{Result, SignResult, error, invalid_key}; +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use ed25519_dalek::{Signer, SigningKey}; +use hmac::{Hmac, Mac}; +use rsa::{ + pkcs1::DecodeRsaPrivateKey, + pkcs8::{DecodePrivateKey, EncodePublicKey}, + traits::PublicKeyParts, +}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use sha2::{Sha384, Sha512}; +use zeroize::Zeroizing; + +pub(super) enum Key { + Ed(SigningKey), + Ec(p256::ecdsa::SigningKey), + Rsa(rsa::RsaPrivateKey), + Mac(Zeroizing>), +} +fn decode(s: &str) -> Result>> { + Ok(Zeroizing::new( + STANDARD + .decode(s) + .or_else(|_| URL_SAFE_NO_PAD.decode(s)) + .map_err(|_| invalid_key())?, + )) +} +fn family(format: &str) -> &str { + match format { + "ed25519-raw" => "ed25519", + "rs256" | "rsa-sha2-256" | "rsa-sha2-512" => "rsa", + s if s.starts_with("hmac-") => "hmac", + s => s, + } +} +pub(super) fn read(value: &str, format: &str, passphrase: Option<&str>) -> Result { + let value = value.trim(); + let family = family(format); + let key = if value.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----") { + let key = ssh_key::PrivateKey::from_openssh(value).map_err(|_| invalid_key())?; + let key = if key.is_encrypted() { + key.decrypt(passphrase.ok_or_else(invalid_key)?) + .map_err(|_| invalid_key())? + } else { + key + }; + match key.key_data() { + ssh_key::private::KeypairData::Ed25519(k) => { + Key::Ed(SigningKey::from_bytes(&k.private.to_bytes())) + } + ssh_key::private::KeypairData::Ecdsa( + k @ ssh_key::private::EcdsaKeypair::NistP256 { .. }, + ) => Key::Ec( + p256::ecdsa::SigningKey::from_slice(k.private_key_bytes()) + .map_err(|_| invalid_key())?, + ), + ssh_key::private::KeypairData::Rsa(k) => { + // ssh-key 0.6.7's TryFrom<&RsaKeypair> accidentally passes p twice. + // Convert the parsed components explicitly, preserving p and q. + let int = |n: &ssh_key::Mpint| rsa::BigUint::try_from(n).map_err(|_| invalid_key()); + let key = rsa::RsaPrivateKey::from_components( + int(&k.public.n)?, + int(&k.public.e)?, + int(&k.private.d)?, + vec![int(&k.private.p)?, int(&k.private.q)?], + ) + .map_err(|_| invalid_key())?; + key.validate().map_err(|_| invalid_key())?; + Key::Rsa(key) + } + _ => return Err(invalid_key()), + } + } else if value.starts_with("-----BEGIN ") { + match family { + "ed25519" => Key::Ed(SigningKey::from_pkcs8_pem(value).map_err(|_| invalid_key())?), + "es256" => { + let key = if value.starts_with("-----BEGIN EC PRIVATE KEY-----") { + p256::SecretKey::from_sec1_pem(value).map_err(|_| invalid_key())? + } else { + p256::SecretKey::from_pkcs8_pem(value).map_err(|_| invalid_key())? + }; + Key::Ec(key.into()) + } + "rsa" => Key::Rsa(if value.starts_with("-----BEGIN RSA PRIVATE KEY-----") { + rsa::RsaPrivateKey::from_pkcs1_pem(value).map_err(|_| invalid_key())? + } else { + rsa::RsaPrivateKey::from_pkcs8_pem(value).map_err(|_| invalid_key())? + }), + _ => return Err(invalid_key()), + } + } else if value.starts_with('{') { + let j: serde_json::Value = serde_json::from_str(value).map_err(|_| invalid_key())?; + let bytes = |name: &str| -> Result>> { + decode(j[name].as_str().ok_or_else(invalid_key)?) + }; + match j["kty"].as_str() { + Some("oct") => Key::Mac(bytes("k")?), + Some("OKP") if j["crv"] == "Ed25519" => { + let d = bytes("d")?; + let k = SigningKey::from_bytes(d.as_slice().try_into().map_err(|_| invalid_key())?); + if bytes("x")?.as_slice() != k.verifying_key().as_bytes() { + return Err(invalid_key()); + } + Key::Ed(k) + } + Some("EC") if j["crv"] == "P-256" => { + let k = + p256::ecdsa::SigningKey::from_slice(&bytes("d")?).map_err(|_| invalid_key())?; + let point = k.verifying_key().to_encoded_point(false); + if bytes("x")?.as_slice() != point.x().ok_or_else(invalid_key)?.as_slice() + || bytes("y")?.as_slice() != point.y().ok_or_else(invalid_key)?.as_slice() + { + return Err(invalid_key()); + } + Key::Ec(k) + } + Some("RSA") => { + let int = |name| -> Result { + Ok(rsa::BigUint::from_bytes_be(&bytes(name)?)) + }; + let k = rsa::RsaPrivateKey::from_components( + int("n")?, + int("e")?, + int("d")?, + vec![int("p")?, int("q")?], + ) + .map_err(|_| invalid_key())?; + k.validate().map_err(|_| invalid_key())?; + Key::Rsa(k) + } + _ => return Err(invalid_key()), + } + } else { + let bytes = decode(value)?; + match family { + "ed25519" => Key::Ed(SigningKey::from_bytes( + bytes.as_slice().try_into().map_err(|_| invalid_key())?, + )), + "es256" if bytes.len() == 32 => { + Key::Ec(p256::ecdsa::SigningKey::from_slice(&bytes).map_err(|_| invalid_key())?) + } + "hmac" => Key::Mac(bytes), + _ => return Err(invalid_key()), + } + }; + if !matches!( + (&key, family), + (Key::Ed(_), "ed25519") + | (Key::Ec(_), "es256") + | (Key::Rsa(_), "rsa") + | (Key::Mac(_), "hmac") + ) { + return Err(invalid_key()); + } + Ok(key) +} +pub(super) fn check(format: &str, padding: Option<&str>, hash: Option<&str>) -> Result<()> { + match format { + "ed25519" | "ed25519-raw" | "es256" | "hmac-sha256" | "hmac-sha384" | "hmac-sha512" + if padding.is_none() && hash.is_none() => + { + Ok(()) + } + "rs256" | "rsa-sha2-256" | "rsa-sha2-512" + if padding == Some("pkcs1v15") + && hash + == Some(if format == "rsa-sha2-512" { + "sha512" + } else { + "sha256" + }) => + { + Ok(()) + } + _ => Err(error( + "invalid_params", + "unknown algorithm or inconsistent padding/hash; RSA requires padding=pkcs1v15 and explicit sha256/sha512 matching the algorithm", + )), + } +} +pub(super) fn sign_key(key: &Key, format: &str, payload: &[u8]) -> Result> { + Ok(match key { + Key::Ed(k) => k.sign(payload).to_bytes().to_vec(), + Key::Ec(k) => { + let s: p256::ecdsa::Signature = k.sign(payload); + s.to_bytes().to_vec() + } + Key::Rsa(k) => if format == "rsa-sha2-512" { + k.sign(rsa::Pkcs1v15Sign::new::(), &Sha512::digest(payload)) + } else { + k.sign(rsa::Pkcs1v15Sign::new::(), &Sha256::digest(payload)) + } + .map_err(|_| invalid_key())?, + Key::Mac(k) => { + macro_rules! mac { + ($h:ty) => {{ + let mut mac = Hmac::<$h>::new_from_slice(k).map_err(|_| invalid_key())?; + mac.update(payload); + mac.finalize().into_bytes().to_vec() + }}; + } + match format { + "hmac-sha384" => mac!(Sha384), + "hmac-sha512" => mac!(Sha512), + _ => mac!(Sha256), + } + } + }) +} +fn string(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + out.extend_from_slice(bytes); +} +fn mpint(out: &mut Vec, bytes: &[u8]) { + let bytes = &bytes[bytes.iter().position(|b| *b != 0).unwrap_or(bytes.len())..]; + let mut positive = Vec::new(); + if bytes.first().is_some_and(|b| b & 128 != 0) { + positive.push(0); + } + positive.extend_from_slice(bytes); + string(out, &positive); +} +fn wire_signature(format: &str, signature: &[u8]) -> Result> { + let (algorithm, signature) = match format { + "ed25519" | "ed25519-raw" => ("ssh-ed25519", signature.to_vec()), + "rs256" | "rsa-sha2-256" => ("rsa-sha2-256", signature.to_vec()), + "rsa-sha2-512" => ("rsa-sha2-512", signature.to_vec()), + "es256" => { + let mut s = Vec::new(); + mpint(&mut s, &signature[..32]); + mpint(&mut s, &signature[32..]); + ("ecdsa-sha2-nistp256", s) + } + _ => { + return Err(error( + "invalid_params", + "SSH wire output requires an asymmetric key", + )); + } + }; + let mut out = Vec::new(); + string(&mut out, algorithm.as_bytes()); + string(&mut out, &signature); + Ok(out) +} +pub(super) fn output(format: &str, output: &str, signature: Vec) -> Result { + let (signature, bytes) = match output { + "raw" => (String::new(), Some(signature)), + "hex" => (hex::encode(signature), None), + "jws" => (URL_SAFE_NO_PAD.encode(signature), None), + "ssh" => (STANDARD.encode(wire_signature(format, &signature)?), None), + "base64" => (STANDARD.encode(signature), None), + _ => return Err(error("invalid_params", "unknown output format")), + }; + Ok(SignResult { signature, bytes }) +} +pub(super) fn public(key: &Key, output: &str) -> Result { + let raw = match key { + Key::Ed(k) => k.verifying_key().to_bytes().to_vec(), + Key::Ec(k) => k + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(), + Key::Rsa(k) => k + .to_public_key() + .to_public_key_der() + .map_err(|_| invalid_key())? + .as_bytes() + .to_vec(), + Key::Mac(_) => return Err(error("invalid_params", "symmetric keys have no public key")), + }; + Ok(match output { + "base64" => STANDARD.encode(&raw), + "hex" => hex::encode(&raw), + "pem" => match key { + Key::Ed(k) => k.verifying_key().to_public_key_pem(Default::default()), + Key::Ec(k) => k.verifying_key().to_public_key_pem(Default::default()), + Key::Rsa(k) => k.to_public_key().to_public_key_pem(Default::default()), + _ => unreachable!(), + }.map_err(|_| invalid_key())?, + "jwk" => match key { + Key::Ed(_) => json!({"kty":"OKP","crv":"Ed25519","x":URL_SAFE_NO_PAD.encode(&raw)}), + Key::Ec(_) => json!({"kty":"EC","crv":"P-256","x":URL_SAFE_NO_PAD.encode(&raw[1..33]),"y":URL_SAFE_NO_PAD.encode(&raw[33..])}), + Key::Rsa(k) => json!({"kty":"RSA","n":URL_SAFE_NO_PAD.encode(k.n().to_bytes_be()),"e":URL_SAFE_NO_PAD.encode(k.e().to_bytes_be())}), + _ => unreachable!(), + }.to_string(), + "ssh" => { + let mut out = Vec::new(); + match key { + Key::Ed(_) => { string(&mut out, b"ssh-ed25519"); string(&mut out, &raw); }, + Key::Ec(_) => { string(&mut out, b"ecdsa-sha2-nistp256"); string(&mut out, b"nistp256"); string(&mut out, &raw); }, + Key::Rsa(k) => { string(&mut out, b"ssh-rsa"); mpint(&mut out, &k.e().to_bytes_be()); mpint(&mut out, &k.n().to_bytes_be()); }, + _ => unreachable!(), + } + STANDARD.encode(out) + } + _ => return Err(error("invalid_params", "unknown public key output")), + }) +} diff --git a/host/src/cred/mod.rs b/host/src/cred/mod.rs index 282e060..5740c6c 100644 --- a/host/src/cred/mod.rs +++ b/host/src/cred/mod.rs @@ -1,3 +1,36 @@ //! The credentials manager (P2, plan §4.4): items, grants, requests and audit in the host, //! outside the world. Programs in the world never see secret values. +//! +//! `Cred::open` loads `cred.sqlite` and the host master key. Read methods return public snapshots. +//! `Caller` carries a binary path and the nearest-first conversation ancestry. +//! `types::Subject` binds grants to a conversation subtree or a binary path. +//! `file_request` normalizes item identities and replays keyed requests. Edit requests carry public diffs. +//! `approve` and `deny` trust the route layer's verified approver identity. +//! That identity can be `None` during bootstrap, before any passkey registration. +//! Each decision commits one SQLite transaction. `subscribe` reports committed decisions across threads. +//! Subscribers must read SQLite after a lag or restart. The durable task layer calls `expire_requests`. +//! `inject` transforms one request's URL and headers. The body stays outside this library. +//! An unmatched request forwards unchanged. An unauthorized match names the item and its filed request. +//! `audit_sign` records signature metadata without the signed payload. +pub mod entry; +pub mod webauthn; +pub mod store; +pub mod schema; +pub mod seal; +pub mod items; +pub mod types; +pub mod grants; +pub mod requests; +pub mod edits; +pub mod summary; +pub mod audit; +pub mod inject; +pub mod error; +pub use store::Cred; +pub use error::{Error, Result}; +pub use types::Caller; pub mod script; +pub mod keys; +pub mod sign; +pub mod oauth2; +pub mod v8; diff --git a/host/src/cred/oauth2.rs b/host/src/cred/oauth2.rs new file mode 100644 index 0000000..6ad2be0 --- /dev/null +++ b/host/src/cred/oauth2.rs @@ -0,0 +1,119 @@ +//! OAuth2 refresh exchange and a process-local cache keyed by item and version. +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Error; +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("OAuth refresh failed or returned an invalid response") + } +} +impl std::error::Error for Error {} + +/// HTTP adapter. Implementations must reject unsuccessful HTTP status codes. +pub trait Http: Sync { + fn send( + &self, + request: &Request, + deadline: Instant, + ) -> impl std::future::Future>, Error>> + Send; +} +/// Contains credentials. Deliberately does not implement Debug or Serialize. +pub struct Request { + pub url: String, + pub method: &'static str, + pub headers: Vec<(&'static str, String)>, + pub body: Zeroizing, +} +pub fn request( + token_url: &str, + refresh_token: &str, + client_id: &str, + client_secret: &str, +) -> Result { + let target = url::Url::parse(token_url).map_err(|_| Error)?; + if target.scheme() != "https" + || !target.username().is_empty() + || target.password().is_some() + || target.fragment().is_some() + || [refresh_token, client_id, client_secret].contains(&"") + { + return Err(Error); + } + let body = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", "refresh_token") + .append_pair("refresh_token", refresh_token) + .append_pair("client_id", client_id) + .append_pair("client_secret", client_secret) + .finish(); + Ok(Request { + url: token_url.into(), + method: "POST", + headers: vec![ + ("Content-Type", "application/x-www-form-urlencoded".into()), + ("Content-Length", body.len().to_string()), + ], + body: Zeroizing::new(body), + }) +} +#[derive(Clone)] +pub struct Token { + pub value: Zeroizing, + pub scopes: Option>, + pub expires: Instant, +} +pub fn decode(bytes: &[u8]) -> Result { + #[derive(serde::Deserialize)] + struct Response { + access_token: String, + token_type: String, + expires_in: u64, + scope: Option, + } + let r: Response = serde_json::from_slice(bytes).map_err(|_| Error)?; + if r.access_token.is_empty() + || !r.token_type.eq_ignore_ascii_case("bearer") + || r.expires_in == 0 + || http::header::HeaderValue::from_str(&format!("Bearer {}", r.access_token)).is_err() + { + return Err(Error); + } + Ok(Token { + value: Zeroizing::new(r.access_token), + scopes: r + .scope + .map(|s| s.split_whitespace().map(str::to_owned).collect()), + expires: Instant::now() + Duration::from_secs(r.expires_in.min(86400).saturating_sub(30)), + }) +} +/// The caller owns synchronization and endpoint policy. No lock crosses an HTTP call here. +#[derive(Default)] +pub struct Cache { + tokens: BTreeMap<(String, i64), Token>, +} +impl Cache { + pub fn clear(&mut self) { + self.tokens.clear(); + } + pub async fn refresh( + &mut self, + item: &str, + version: i64, + request: &Request, + http: &impl Http, + deadline: Instant, + ) -> Result { + let key = (item.to_owned(), version); + if let Some(t) = self.tokens.get(&key).filter(|t| t.expires > Instant::now()) { + return Ok(t.clone()); + } + self.tokens.retain(|(id, _), _| id != item); + let token = decode(&http.send(request, deadline).await?)?; + self.tokens.insert(key, token.clone()); + Ok(token) + } +} diff --git a/host/src/cred/requests.rs b/host/src/cred/requests.rs new file mode 100644 index 0000000..90b0bef --- /dev/null +++ b/host/src/cred/requests.rs @@ -0,0 +1,437 @@ +//! Keyed grant requests, transactional decisions, explicit expiry and decision notifications. +use super::{ + audit, + error::{Error, Result}, + grants::{self, Grant}, + seal, + store::{Cred, now}, + types::{Caller, Subject}, +}; +use rusqlite::{OptionalExtension, TransactionBehavior, params}; +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct GrantRequest { + pub items: Vec, + pub duration_secs: Option, + pub max_uses: Option, + pub validator: Option, +} +#[derive(Clone, Debug, serde::Serialize)] +pub struct Request { + pub id: String, + pub subject: Subject, + pub payload: serde_json::Value, + pub state: String, + pub expires: i64, + pub pid: u32, + pub program: String, + pub decision: Option, + pub approver: Option, +} +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct Overrides { + #[serde(default)] + pub unlimited: bool, + pub duration_secs: Option, + pub max_uses: Option, + pub validator: Option, +} +fn normalised(payload: &serde_json::Value) -> serde_json::Value { + let mut value = if let Some(requested) = payload.get("requested") { + serde_json::json!({"requested":requested}) + } else { + payload.clone() + }; + if let Some(object) = value.as_object_mut() { + object.remove("diff"); + object.remove("old"); + } + fn sets(value: &mut serde_json::Value, ordered: bool) { + match value { + serde_json::Value::Object(object) => { + for (key, value) in object { + sets(value, key == "change" || key == "fields"); + } + } + serde_json::Value::Array(values) => { + for value in values.iter_mut() { + sets(value, false); + } + if !ordered { + values.sort_by_cached_key(|v| v.to_string()); + values.dedup(); + } + } + _ => {} + } + } + sets(&mut value, false); + value +} +impl Cred { + pub(crate) fn replay( + &self, + caller: &Caller, + key: &str, + payload: &serde_json::Value, + ) -> Result> { + let s = self.access()?; + let subject = serde_json::to_string(&Subject::requester(caller))?; + let old:Option<(String,String,String,i64,Option)>=s.db.query_row("SELECT id,payload,state,expires,grant_id FROM requests WHERE subject=?1 AND key=?2 ORDER BY rowid DESC LIMIT 1",params![subject,key],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?; + let Some((id, prior, state, expires, grant_id)) = old else { + return Ok(None); + }; + if normalised(&serde_json::from_str(&prior)?) != normalised(payload) { + return Err(Error("key_conflict")); + } + let usable = match grant_id { + None => true, + Some(id) => grants::list(&s.db)?.iter().any(|g| { + g.id == id + && !g.revoked + && g.exp.is_none_or(|e| e > now()) + && g.max_uses.is_none_or(|m| g.uses < m) + }), + }; + Ok( + ((state == "pending" && expires > now()) || (state == "approved" && usable)) + .then_some(id), + ) + } + pub fn file_request( + &self, + caller: &Caller, + key: &str, + mut payload: GrantRequest, + ) -> Result { + if payload.items.is_empty() { + return Err(Error("invalid_items")); + } + { + let s = self.access()?; + for item in &mut payload.items { + *item = + s.db.query_row( + "SELECT id FROM items WHERE id=?1 OR name=?1 ORDER BY id=?1 DESC LIMIT 1", + [&*item], + |r| r.get(0), + ) + .optional()? + .unwrap_or_else(|| item.clone()); + } + } + payload.items.sort(); + payload.items.dedup(); + payload.duration_secs = Some(payload.duration_secs.unwrap_or(3600)); + if payload.validator.as_deref() == Some("") { + payload.validator = None; + } + let value = serde_json::to_value(&payload)?; + if let Some(id) = self.replay(caller, key, &value)? { + return Ok(id); + } + { + let s = self.access()?; + for item in &payload.items { + super::items::load(&s.db, item)?; + } + } + self.file_payload(caller, key, value) + } + pub(crate) fn file_payload( + &self, + caller: &Caller, + key: &str, + payload: serde_json::Value, + ) -> Result { + let mut s = self.access()?; + let tx = + s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let mut expired_id = None; + let subject = Subject::requester(caller); + let subject_json = serde_json::to_string(&subject)?; + let encoded = serde_json::to_string(&payload)?; + let old:Option<(String,String,String,i64,Option)>=tx.query_row("SELECT id,payload,state,expires,grant_id FROM requests WHERE subject IS ?1 AND key=?2 ORDER BY rowid DESC LIMIT 1",params![subject_json,key],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?))).optional()?; + if let Some((id, prior, state, expires, grant_id)) = old { + if normalised(&serde_json::from_str(&prior)?) != normalised(&payload) { + return Err(Error("key_conflict")); + } + if state == "pending" && expires > now() { + return Ok(id); + } + if state == "approved" + && match grant_id { + None => true, + Some(grant_id) => grants::list(&tx)?.iter().any(|g| { + g.id == grant_id + && !g.revoked + && g.exp.is_none_or(|e| e > now()) + && g.max_uses.is_none_or(|m| g.uses < m) + }), + } + { + return Ok(id); + } + if state == "pending" { + tx.execute("UPDATE requests SET state='expired' WHERE id=?", [&id])?; + expired_id = Some(id); + } + } + let id = seal::identity(); + tx.execute("INSERT INTO requests(id,subject,key,payload,state,expires,pid,program) VALUES(?1,?2,?3,?4,'pending',?5,?6,?7)",params![id,subject_json,key,encoded,now()+86400,caller.pid,caller.program])?; + tx.commit()?; + if let Some(expired) = expired_id { + let _ = self.decided.send(expired); + } + let _ = self.filed.send(id.clone()); + Ok(id) + } + pub fn request_list(&self) -> Result> { + let s = self.access()?; + let mut q=s.db.prepare("SELECT id,subject,payload,state,expires,pid,decision,approver,program FROM requests ORDER BY rowid")?; + let rows = q.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + r.get(8)?, + )) + })?; + rows.map(|r| { + let (id, subject, payload, state, expires, pid, decision, approver, program) = r?; + Ok(Request { + id, + subject: serde_json::from_str(&subject)?, + payload: serde_json::from_str(&payload)?, + state, + expires, + pid, + program, + decision, + approver, + }) + }) + .collect() + } + pub fn request_diff(&self, id: &str) -> Result { + self.request_list()? + .into_iter() + .find(|r| r.id == id) + .map(|r| r.payload) + .ok_or(Error("request_missing")) + } + pub fn request_decisions(&self) -> Result> { + Ok(self + .request_list()? + .into_iter() + .filter(|r| r.state != "pending") + .collect()) + } + pub fn approve( + &self, + id: &str, + decision: &str, + overrides: Overrides, + approver: Option<&str>, + ) -> Result<()> { + self.decide(id, decision, Some(overrides), approver) + } + pub fn deny(&self, id: &str, decision: &str, approver: Option<&str>) -> Result<()> { + self.decide(id, decision, None, approver) + } + fn decide( + &self, + id: &str, + decision: &str, + overrides: Option, + approver: Option<&str>, + ) -> Result<()> { + let mut s = self.access()?; + let master = s.master.clone(); + let tx = + s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let (subject, payload, state, expires, pid, program): ( + String, + String, + String, + i64, + u32, + String, + ) = tx.query_row( + "SELECT subject,payload,state,expires,pid,program FROM requests WHERE id=?", + [id], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + )) + }, + )?; + let subject: Subject = serde_json::from_str(&subject)?; + if state == "expired" { + return Err(Error("request_expired")); + } + if state != "pending" { + return Err(Error("already_decided")); + } + if expires <= now() { + tx.execute("UPDATE requests SET state='expired' WHERE id=?", [id])?; + tx.commit()?; + let _ = self.decided.send(id.to_owned()); + return Err(Error("request_expired")); + } + let caller = Caller { + pid, + program, + conversations: match &subject { + Subject::Conversation(id) => vec![id.clone()], + Subject::Program(_) => vec![], + }, + }; + let mut issued_grant = None; + let state = if let Some(o) = overrides { + let parsed: serde_json::Value = serde_json::from_str(&payload)?; + if let Some(edit) = parsed.get("edit") { + let mut edit: super::edits::Edit = serde_json::from_value(edit.clone())?; + if let Some(requested) = parsed.get("requested") { + let requested: super::edits::OperationRequest = + serde_json::from_value(requested.clone())?; + let current = super::items::load(&tx, &edit.item)?; + if current.version != edit.base_version { + return Err(Error("stale")); + } + edit.config = super::edits::apply_changes( + current, + &requested.change, + 0, + Some((&tx, &master)), + )?; + } + super::edits::apply(&tx, &master, &edit, &caller, approver)?; + } else if let Some(revert) = parsed.get("revert") { + super::edits::apply_revert( + &tx, + &master, + &serde_json::from_value(revert.clone())?, + &caller, + approver, + )?; + } else if let Some(create) = parsed.get("create") { + let mut item = super::items::insert_new( + &tx, + serde_json::from_value(create.clone())?, + &caller, + approver, + )?; + if parsed.get("generate_es256").and_then(|v| v.as_bool()) == Some(true) { + let private = + super::sign::generate_es256().map_err(|_| Error("key_generation"))?; + let index = item + .fields + .iter() + .position(|f| { + !f.public + && f.field_type == super::types::FieldType::SshPrivateKey + && f.key_type.as_deref() == Some("es256") + }) + .ok_or(Error("invalid_params"))?; + let field = &item.fields[index].field_id; + let cipher = + seal::seal(&master, &format!("{}|{field}", item.id), private.as_bytes())?; + tx.execute( + "INSERT INTO fields VALUES(?1,?2,?3)", + params![item.id, field, cipher], + )?; + item.fields[index].needs_value = false; + super::items::save(&tx, &item)?; + } + } else if let Some(grant) = parsed.get("grant_create") { + let mut grant: Grant = serde_json::from_value(grant.clone())?; + grant.id = seal::identity(); + grant.uses = 0; + grant.revoked = false; + grants::insert(&tx, &grant, &caller, approver)?; + } else if let Some(target) = parsed.get("grant_revoke") { + let target = target.as_str().ok_or(Error("invalid_params"))?; + if tx.execute("UPDATE grants SET revoked=1 WHERE id=?", [target])? != 1 { + return Err(Error("grant_missing")); + } + audit::record(&tx, "grant.revoke", &caller, None, approver)?; + } else if let Some(delete) = parsed.get("delete") { + super::edits::apply_delete( + &tx, + &serde_json::from_value(delete.clone())?, + &caller, + approver, + )?; + } else { + let p: GrantRequest = serde_json::from_value(parsed)?; + let duration = o.duration_secs.or(p.duration_secs).unwrap_or(3600); + if duration == 0 { + return Err(Error("invalid_duration")); + } + let duration = i64::try_from(duration).map_err(|_| Error("invalid_duration"))?; + let g = Grant { + id: seal::identity(), + items: p.items, + subject, + exp: Some( + now() + .checked_add(duration) + .ok_or(Error("invalid_duration"))?, + ), + max_uses: if o.unlimited { + None + } else { + o.max_uses.or(p.max_uses) + }, + uses: 0, + revoked: false, + validator: o.validator.or(p.validator), + note: None, + }; + grants::insert(&tx, &g, &caller, approver)?; + issued_grant = Some(g.id); + } + "approved" + } else { + "denied" + }; + tx.execute( + "UPDATE requests SET state=?2,decision=?3,approver=?4,grant_id=?5 WHERE id=?1", + params![id, state, decision, approver, issued_grant], + )?; + audit::record(&tx, state, &caller, None, approver)?; + tx.commit()?; + let _ = self.decided.send(id.to_owned()); + Ok(()) + } + pub fn expire_requests(&self) -> Result> { + let mut s = self.access()?; + let tx = + s.db.transaction_with_behavior(TransactionBehavior::Immediate)?; + let ids = { + let mut q = + tx.prepare("SELECT id FROM requests WHERE state='pending' AND expires<=?")?; + q.query_map([now()], |r| r.get::<_, String>(0))? + .collect::>>()? + }; + tx.execute( + "UPDATE requests SET state='expired' WHERE state='pending' AND expires<=?", + [now()], + )?; + tx.commit()?; + for id in &ids { + let _ = self.decided.send(id.clone()); + } + Ok(ids) + } +} diff --git a/host/src/cred/schema.rs b/host/src/cred/schema.rs new file mode 100644 index 0000000..9dd0a4b --- /dev/null +++ b/host/src/cred/schema.rs @@ -0,0 +1,12 @@ +//! Fresh credentials schema. No v2 migrations or vault lifecycle. +pub(crate) const DDL: &str = " +PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; +CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, config TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS fields(item TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, id TEXT NOT NULL, ciphertext BLOB NOT NULL, PRIMARY KEY(item,id)); +CREATE TABLE IF NOT EXISTS versions(item TEXT NOT NULL, created INTEGER NOT NULL, config TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS grants(id TEXT PRIMARY KEY, items TEXT NOT NULL, subject TEXT NOT NULL, exp INTEGER, max_uses INTEGER, uses INTEGER NOT NULL DEFAULT 0, revoked INTEGER NOT NULL DEFAULT 0, validator TEXT, note TEXT); +CREATE TABLE IF NOT EXISTS requests(id TEXT PRIMARY KEY, subject TEXT, key TEXT NOT NULL, payload TEXT NOT NULL, state TEXT NOT NULL, expires INTEGER NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, decision TEXT, approver TEXT, grant_id TEXT); +CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY, time INTEGER NOT NULL, event TEXT NOT NULL, pid INTEGER NOT NULL, program TEXT NOT NULL, conversation TEXT, item TEXT, approver TEXT, payload_sha256 TEXT); +CREATE INDEX IF NOT EXISTS request_keys ON requests(subject,key); +CREATE INDEX IF NOT EXISTS request_expiry ON requests(state,expires); +"; diff --git a/host/src/cred/seal.rs b/host/src/cred/seal.rs new file mode 100644 index 0000000..a916637 --- /dev/null +++ b/host/src/cred/seal.rs @@ -0,0 +1,57 @@ +//! Authenticated field encryption. The field identity binds each ciphertext. +use super::error::{Error, Result}; +use chacha20poly1305::{ + XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit, OsRng, Payload, rand_core::RngCore}, +}; +pub(crate) fn random() -> [u8; N] { + let mut bytes = [0; N]; + OsRng.fill_bytes(&mut bytes); + bytes +} +pub(crate) fn identity() -> String { + hex::encode(random::<16>()) +} +pub(crate) fn seal(key: &[u8; 32], aad: &str, value: &[u8]) -> Result> { + let nonce = random::<24>(); + let cipher = XChaCha20Poly1305::new(key.into()); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: value, + aad: aad.as_bytes(), + }, + ) + .map_err(|_| Error("seal"))?; + Ok([nonce.to_vec(), ciphertext].concat()) +} +pub(crate) fn open(key: &[u8; 32], aad: &str, value: &[u8]) -> Result>> { + if value.len() < 24 { + return Err(Error("seal")); + } + XChaCha20Poly1305::new(key.into()) + .decrypt( + XNonce::from_slice(&value[..24]), + Payload { + msg: &value[24..], + aad: aad.as_bytes(), + }, + ) + .map(zeroize::Zeroizing::new) + .map_err(|_| Error("seal")) +} +#[cfg(test)] +mod tests { + #[test] + fn field_seal_rejects_tamper_and_wrong_identity() { + let key = super::random::<32>(); + let mut cipher = super::seal(&key, "item|field", b"value").unwrap(); + assert!(super::open(&key, "item|field", &cipher).unwrap().as_slice() == b"value"); + assert!(super::open(&key, "other|field", &cipher).is_err()); + assert!(super::open(&super::random::<32>(), "item|field", &cipher).is_err()); + let end = cipher.len() - 1; + cipher[end] ^= 1; + assert!(super::open(&key, "item|field", &cipher).is_err()); + } +} diff --git a/host/src/cred/sign.rs b/host/src/cred/sign.rs new file mode 100644 index 0000000..c2c0a12 --- /dev/null +++ b/host/src/cred/sign.rs @@ -0,0 +1,61 @@ +//! Pure signing and public-key export over decrypted field values. No storage or grant policy. +use super::keys; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error { + pub kind: &'static str, + pub message: &'static str, +} +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message) + } +} +impl std::error::Error for Error {} +pub type Result = std::result::Result; +pub(super) fn error(kind: &'static str, message: &'static str) -> Error { + Error { kind, message } +} +pub(super) fn invalid_key() -> Error { + error("invalid_key", "invalid signing key encoding or length") +} + +#[derive(Debug, serde::Serialize)] +pub struct SignResult { + pub signature: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub bytes: Option>, +} + +/// Sign bytes. RSA requires explicit, matching padding and digest parameters. +pub fn sign( + value: &str, + passphrase: Option<&str>, + format: &str, + payload: &[u8], + output: &str, + padding: Option<&str>, + hash: Option<&str>, +) -> Result { + keys::check(format, padding, hash)?; + let key = keys::read(value, format, passphrase)?; + keys::output(format, output, keys::sign_key(&key, format, payload)?) +} + +/// Export only the public part of an asymmetric key. +pub fn public_key( + value: &str, + passphrase: Option<&str>, + format: &str, + output: &str, +) -> Result { + keys::public(&keys::read(value, format, passphrase)?, output) +} + +/// Generate a P-256 private key in PKCS#8 PEM format for ES256. +pub fn generate_es256() -> Result> { + use p256::pkcs8::EncodePrivateKey; + p256::SecretKey::random(&mut rsa::rand_core::OsRng) + .to_pkcs8_pem(Default::default()) + .map_err(|_| invalid_key()) +} diff --git a/host/src/cred/store.rs b/host/src/cred/store.rs new file mode 100644 index 0000000..48118b8 --- /dev/null +++ b/host/src/cred/store.rs @@ -0,0 +1,78 @@ +//! Host-only database and master key. One mutex protects synchronous transactions. +use super::{ + error::{Error, Result}, + schema, seal, +}; +use std::{ + io::{Read, Write}, + os::unix::fs::OpenOptionsExt, + path::Path, + sync::{Mutex, MutexGuard}, +}; +pub(crate) struct Store { + pub db: rusqlite::Connection, + pub master: zeroize::Zeroizing<[u8; 32]>, +} +pub struct Cred { + pub(crate) store: Mutex, + pub(crate) decided: tokio::sync::broadcast::Sender, + pub(crate) filed: tokio::sync::broadcast::Sender, +} +impl Cred { + pub fn open(host_dir: &Path) -> Result { + std::fs::create_dir_all(host_dir)?; + let path = std::env::var_os("CREDENTIALS_DIRECTORY") + .map(|p| std::path::PathBuf::from(p).join("sausage-cred-key")) + .unwrap_or_else(|| host_dir.join("cred.key")); + if std::env::var_os("CREDENTIALS_DIRECTORY").is_none() { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + { + Ok(mut file) => { + file.write_all(&seal::random::<32>())?; + file.sync_all()?; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + } + let mut bytes = zeroize::Zeroizing::new(Vec::new()); + std::fs::File::open(path)?.read_to_end(&mut bytes)?; + let master = zeroize::Zeroizing::new( + bytes + .as_slice() + .try_into() + .map_err(|_| Error("master_key"))?, + ); + let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?; + db.busy_timeout(std::time::Duration::from_secs(5))?; + db.execute_batch(schema::DDL)?; + let (decided, _) = tokio::sync::broadcast::channel(64); + let (filed, _) = tokio::sync::broadcast::channel(64); + let cred = Self { + store: Mutex::new(Store { db, master }), + decided, + filed, + }; + cred.expire_requests()?; + Ok(cred) + } + pub(crate) fn access(&self) -> Result> { + self.store.lock().map_err(|_| Error("storage")) + } + pub fn subscribe_filed(&self) -> tokio::sync::broadcast::Receiver { + self.filed.subscribe() + } + pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { + self.decided.subscribe() + } +} +pub(crate) fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock before epoch") + .as_secs() as i64 +} diff --git a/host/src/cred/summary.rs b/host/src/cred/summary.rs new file mode 100644 index 0000000..7316f94 --- /dev/null +++ b/host/src/cred/summary.rs @@ -0,0 +1,111 @@ +//! Public approval summaries. Do not interpolate field values or script sources. +pub fn human_duration(mut seconds: u64) -> String { + let mut parts = Vec::new(); + for (size, unit) in [ + (86400, "day"), + (3600, "hour"), + (60, "minute"), + (1, "second"), + ] { + let count = seconds / size; + seconds %= size; + if count != 0 { + parts.push(format!( + "{count} {unit}{}", + if count == 1 { "" } else { "s" } + )); + } + } + if parts.is_empty() { + "0 seconds".into() + } else { + parts.join(" ") + } +} +fn text(value: &str) -> String { + let mut out = String::new(); + for c in value.chars() { + match c { + '\n' | '\r' => out.push(' '), + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '\\' | '`' | '*' | '_' | '[' | ']' | '#' | '|' => { + out.push('\\'); + out.push(c); + } + _ => out.push(c), + } + } + out +} +pub fn operation_summary(operation: &super::edits::Operation) -> String { + use super::edits::Operation::*; + match operation { + CreateItem { config } => format!("Create item “{}”.", text(&config.name)), + GenerateEs256Item { config } => { + format!("Create item “{}” with a new ES256 key.", text(&config.name)) + } + SetName { name } => format!("Rename the item to “{}”.", text(name)), + SetRules { .. } => "Replace the access rules. Review the rules in Details.".into(), + SetFreeUse { enabled: true } => { + "Allow anyone to use this item without a grant, within its rules.".into() + } + SetFreeUse { enabled: false } => "Require a grant to use this item.".into(), + SetValidateJs { value: None } => "Remove the validation script.".into(), + SetValidateJs { .. } => "Replace the validation script.".into(), + SetTransformJs { .. } => "Replace the transformation script.".into(), + AddField { field } => format!( + "Add {} field “{}”.", + if field.public { "public" } else { "secret" }, + text(&field.name) + ), + SetFieldValue { .. } => "Change a public field value.".into(), + SetFieldType { .. } => "Change a field type. Review the type in Details.".into(), + RenameField { name, .. } => format!("Rename a field to “{}”.", text(name)), + RemoveField { .. } => "Remove a field and permanently discard its value.".into(), + DeleteItem => "Delete the item and permanently discard its field values.".into(), + ItemRevert { version } => format!( + "Restore public configuration from revision {version}. Discarded secret values cannot be recovered." + ), + GrantCreate { grant } => format!( + "Give access to {} credential items. Review the subject and limits in Details.", + grant.items.len() + ), + GrantRevoke { .. } => "Revoke the grant. All access through this grant will stop.".into(), + } +} +/// UI presets. The runtime never interprets a preset name. +pub fn script(preset: &str) -> super::Result { + let field = "const get = name => { if (typeof credential[name] !== 'string') throw Error('credential field is missing'); return credential[name]; };"; + if let Some(name) = preset.strip_prefix("query:") { + let name = serde_json::to_string(name)?; + return Ok(format!( + r#"export default ({{request,credential,url}}) => {{ + {field} + const name = {name}; + const encode = value => encodeURIComponent(value).replace(/[!'()~]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()).replace(/%20/g, '+'); + const pairs = url.query.filter(([key]) => key !== name); + pairs.push([name,get('token')]); + return {{...request,url:url.base+'?'+pairs.map(([k,v])=>encode(k)+'='+encode(v)).join('&')+url.fragment}}; +}}"# + )); + } + let (name, value) = match preset { + "bearer" => ("Authorization", "'Bearer ' + get('token')"), + "basic" => ( + "Authorization", + "'Basic ' + btoa(unescape(encodeURIComponent(get('username') + ':' + get('password'))))", + ), + p if p.starts_with("header:") => (&p[7..], "get('token')"), + _ => return Err(super::Error("invalid_preset")), + }; + let name = serde_json::to_string(name)?; + Ok(format!( + r#"export default ({{request,credential}}) => {{ + {field} + const name = {name}; + return {{...request,headers:[...request.headers.filter(h=>h.name.toLowerCase()!==name.toLowerCase()),{{name,value:{value}}}]}}; +}}"# + )) +} diff --git a/host/src/cred/types.rs b/host/src/cred/types.rs new file mode 100644 index 0000000..8cae2af --- /dev/null +++ b/host/src/cred/types.rs @@ -0,0 +1,108 @@ +//! Caller identity and mandatory field metadata. Reject mismatched key algorithms. +use super::error::{Error, Result}; +use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Caller { + pub program: String, + pub pid: u32, + pub conversations: Vec, +} +/// Conversation subjects cover descendants. Program subjects cover the same binary path. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "id", rename_all = "snake_case")] +pub enum Subject { + Conversation(String), + Program(String), +} +impl Subject { + pub fn covers(&self, caller: &Caller) -> bool { + match self { + Self::Conversation(id) => caller.conversations.contains(id), + Self::Program(path) => caller.program == *path, + } + } + pub(crate) fn requester(caller: &Caller) -> Self { + caller.conversations.first().map_or_else( + || Self::Program(caller.program.clone()), + |id| Self::Conversation(id.clone()), + ) + } + pub(crate) fn valid(&self) -> bool { + match self { + Self::Conversation(id) => !id.is_empty(), + Self::Program(path) => path.starts_with('/'), + } + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FieldType { + Text, + Note, + SshPublicKey, + SshPrivateKey, + Oauth2RefreshToken, +} +fn matches_algorithm(algorithm: ssh_key::Algorithm, format: &str) -> bool { + match algorithm { + ssh_key::Algorithm::Ed25519 => matches!(format, "ed25519" | "ed25519-raw"), + ssh_key::Algorithm::Ecdsa { + curve: ssh_key::EcdsaCurve::NistP256, + } => format == "es256", + ssh_key::Algorithm::Rsa { .. } => { + matches!(format, "rs256" | "rsa-sha2-256" | "rsa-sha2-512") + } + _ => false, + } +} +pub(crate) fn validate( + kind: &FieldType, + key_type: Option<&str>, + public: bool, + value: Option<&str>, +) -> Result<()> { + let key_format = key_type.is_some_and(|k| { + matches!( + k, + "ed25519" | "ed25519-raw" | "es256" | "rs256" | "rsa-sha2-256" | "rsa-sha2-512" + ) + }); + match kind { + FieldType::Text | FieldType::Note if key_type.is_none() => {} + FieldType::Oauth2RefreshToken if !public && key_type.is_none() => {} + FieldType::SshPublicKey if public && key_format => {} + FieldType::SshPrivateKey if !public && key_format => {} + _ => return Err(Error("invalid_type")), + } + if let Some(value) = value { + match kind { + FieldType::Text | FieldType::Oauth2RefreshToken if value.contains(['\r', '\n']) => { + return Err(Error("invalid_value")); + } + FieldType::SshPublicKey => { + let key = + ssh_key::PublicKey::from_openssh(value).map_err(|_| Error("invalid_value"))?; + if !matches_algorithm(key.algorithm(), key_type.unwrap_or("")) { + return Err(Error("invalid_value")); + } + } + FieldType::SshPrivateKey => { + if value + .trim() + .starts_with("-----BEGIN OPENSSH PRIVATE KEY-----") + { + let key = ssh_key::PrivateKey::from_openssh(value) + .map_err(|_| Error("invalid_value"))?; + if !matches_algorithm(key.algorithm(), key_type.unwrap_or("")) { + return Err(Error("invalid_value")); + } + } else { + super::keys::read(value, key_type.unwrap_or(""), None) + .map_err(|_| Error("invalid_value"))?; + } + } + _ => {} + } + } + Ok(()) +} diff --git a/host/src/cred/v8.rs b/host/src/cred/v8.rs new file mode 100644 index 0000000..3b2f1ad --- /dev/null +++ b/host/src/cred/v8.rs @@ -0,0 +1,199 @@ +//! Fresh, bounded V8 isolates for credential scripts. Each call owns a dedicated worker thread. +use super::script::{Error, ErrorKind, Limits, Script}; +use deno_core::{JsRuntime, RuntimeOptions, v8}; +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + time::Duration, +}; + +#[derive(Default)] +pub struct V8; +fn failure(kind: ErrorKind) -> Error { + Error { + kind, + message: match kind { + ErrorKind::Limit => "script time, memory or stack limit exceeded", + ErrorKind::Syntax => "invalid script or JSON", + ErrorKind::Throw => "script threw or rejected", + ErrorKind::NonJson => "result is not JSON", + ErrorKind::Engine => "script engine failure", + } + .into(), + } +} +impl Script for V8 { + fn evaluate( + &self, + source: &str, + input: &str, + export: Option<&str>, + limits: Limits, + ) -> Result { + let source = source.to_owned(); + let input = input.to_owned(); + let export = export.unwrap_or("default").to_owned(); + std::thread::spawn(move || evaluate(source, input, export, limits)) + .join() + .map_err(|_| failure(ErrorKind::Engine))? + } +} +fn evaluate( + source: String, + input: String, + export: String, + limits: Limits, +) -> Result { + let heap = limits + .max_memory_bytes + .unwrap_or(8 * 1024 * 1024) + .clamp(256 * 1024, 32 * 1024 * 1024); + // V8 uses Tokio for internal delayed tasks, even without guest extensions. + let executor = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .map_err(|_| failure(ErrorKind::Engine))?; + let _entered = executor.enter(); + let mut runtime = JsRuntime::new(RuntimeOptions { + // Bootstrap before imposing the caller's budget: V8 cannot recover from + // bootstrap OOM because the runtime cannot install its callback yet. + create_params: Some(v8::CreateParams::default().heap_limits(0, 32 * 1024 * 1024)), + ..Default::default() + }); + if runtime.v8_isolate().get_heap_statistics().total_heap_size() > heap { + return Err(failure(ErrorKind::Limit)); + } + // Removing a temporary callback sets the heap limit after bootstrap. + runtime.add_near_heap_limit_callback(|current, _| current); + runtime.remove_near_heap_limit_callback(heap); + let handle = runtime.v8_isolate().thread_safe_handle(); + let limited = Arc::new(AtomicBool::new(false)); + let flag = limited.clone(); + let heap_handle = handle.clone(); + runtime.add_near_heap_limit_callback(move |current, _| { + flag.store(true, Ordering::SeqCst); + heap_handle.terminate_execution(); + // V8 needs emergency headroom to unwind after termination. + current + 16 * 1024 * 1024 + }); + let (done, wait) = mpsc::channel(); + let flag = limited.clone(); + let watchdog = std::thread::spawn(move || { + if wait + .recv_timeout(Duration::from_millis( + limits.max_time_ms.unwrap_or(100).clamp(1, 1000), + )) + .is_err() + { + flag.store(true, Ordering::SeqCst); + handle.terminate_execution(); + } + }); + let task = async { + runtime + .execute_script("sandbox.js", BOOTSTRAP) + .map_err(|_| failure(ErrorKind::Engine))?; + let input = runtime + .execute_script( + "input.js", + format!("JSON.parse({})", serde_json::to_string(&input).unwrap()), + ) + .map_err(|_| failure(ErrorKind::Syntax))?; + let spec = deno_core::ModuleSpecifier::parse("file:///credential.js").unwrap(); + let module = runtime + .load_main_es_module_from_code(&spec, source) + .await + .map_err(|_| failure(ErrorKind::Syntax))?; + let evaluated = runtime.mod_evaluate(module); + runtime + .with_event_loop_promise(evaluated, Default::default()) + .await + .map_err(|e| { + if e.to_string().contains("call stack") { + failure(ErrorKind::Limit) + } else { + failure(ErrorKind::Throw) + } + })?; + let ns = runtime + .get_module_namespace(module) + .map_err(|_| failure(ErrorKind::Engine))?; + { + deno_core::scope!(scope, &mut runtime); + let ns = v8::Local::new(scope, ns); + let global = scope.get_current_context().global(scope); + let key = + v8::String::new(scope, "__module").ok_or_else(|| failure(ErrorKind::Limit))?; + global.set(scope, key.into(), ns.into()); + let input = v8::Local::new(scope, input); + let key = v8::String::new(scope, "__input").ok_or_else(|| failure(ErrorKind::Limit))?; + global.set(scope, key.into(), input); + } + let code = format!( + r#"(async()=>{{ + const f=__module[{}]; + if(typeof f!=='function') return JSON.stringify({{kind:'Engine'}}); + let value; + try {{ value=await f(__input); }} catch(e) {{ const message=String(e?.message); return JSON.stringify({{kind: e instanceof RangeError && message.includes('call stack') ? 'Limit':'Throw'}}); }} + try {{ const text=JSON.stringify(value); return JSON.stringify(text===undefined?{{kind:'NonJson'}}:{{text}}); }} catch(e) {{ return JSON.stringify({{kind:e instanceof RangeError && String(e.message).includes('call stack') ? 'Limit':'NonJson'}}); }} + }})()"#, + serde_json::to_string(&export).unwrap() + ); + let value = runtime + .execute_script("call.js", code) + .map_err(|_| failure(ErrorKind::Throw))?; + let resolve = runtime.resolve(value); + let value = runtime + .with_event_loop_promise(resolve, Default::default()) + .await + .map_err(|_| failure(ErrorKind::Engine))?; + let text = { + deno_core::scope!(scope, &mut runtime); + v8::Local::new(scope, value).to_rust_string_lossy(scope) + }; + let result: serde_json::Value = + serde_json::from_str(&text).map_err(|_| failure(ErrorKind::Engine))?; + match result["text"].as_str() { + Some(s) => Ok(s.to_owned()), + None => Err(failure(match result["kind"].as_str() { + Some("NonJson") => ErrorKind::NonJson, + Some("Throw") => ErrorKind::Throw, + Some("Limit") => ErrorKind::Limit, + _ => ErrorKind::Engine, + })), + } + }; + let result = executor.block_on(task); + let _ = done.send(()); + let _ = watchdog.join(); + if limited.load(Ordering::SeqCst) { + Err(failure(ErrorKind::Limit)) + } else { + result + } +} +const BOOTSTRAP: &str = r#" +delete globalThis.Date; delete Math.random; +for (const name of ['Deno','__bootstrap','console','performance']) delete globalThis[name]; +const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +globalThis.btoa = value => { + const s=String(value); let out=''; + for(let i=0;i255||b>255||c>255) throw Error('invalid base64 input'); + const n=(a<<16)|((b||0)<<8)|(c||0); + out+=alphabet[(n>>>18)&63]+alphabet[(n>>>12)&63]+(i+1>>6)&63]:'=')+(i+2 { + let s=String(value).replace(/[\t\n\f\r ]/g,''); + if(s.length%4===0) s=s.replace(/==?$/,''); + if(s.length%4===1||/[^A-Za-z0-9+/]/.test(s)) throw Error('invalid base64 input'); + let out='', bits=0, n=0; + for(const c of s){n=(n<<6)|alphabet.indexOf(c);bits+=6;if(bits>=8){bits-=8;out+=String.fromCharCode((n>>>bits)&255);}} + return out; +}; +"#; diff --git a/host/src/cred/webauthn.rs b/host/src/cred/webauthn.rs new file mode 100644 index 0000000..f40417f --- /dev/null +++ b/host/src/cred/webauthn.rs @@ -0,0 +1,247 @@ +//! Passkey enrollment and single-use assertions for host-owned bindings. +//! The caller supplies cred.sqlite and serializes access to this manager. +//! Browser responses and challenges use WebAuthn JSON. Enrollment assertions +//! must bind the exact new label. Neither decision nor secret-entry assertions +//! authorize enrollment. Without passkeys, the host is unlocked and permits +//! first registration without a gate. Restart drops all pending challenges. +use anyhow::{Result, bail}; +use rusqlite::{Connection, params}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; +use webauthn_rs::prelude::*; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Binding { + Decision { + request_id: String, + decision: String, + overrides: serde_json::Value, + }, + SecretEntry { + item: String, + field: String, + ciphertext_sha256: String, + }, + Enrollment { + label: String, + }, +} + +#[derive(Serialize, Deserialize)] +pub struct Gate { + pub id: String, + pub response: PublicKeyCredential, +} + +#[derive(Serialize, Deserialize)] +pub struct Challenge { + pub id: String, + pub options: T, +} + +struct Pending { + deadline: Instant, + value: T, +} + +pub struct Passkeys<'a> { + db: &'a Connection, + webauthn: Webauthn, + registrations: HashMap>, + assertions: HashMap>, +} + +impl<'a> Passkeys<'a> { + pub fn new(db: &'a Connection, rp_id: &str, origin: &str) -> Result { + let origin = Url::parse(origin)?; + let webauthn = WebauthnBuilder::new(rp_id, &origin)?.build()?; + db.execute_batch( + "CREATE TABLE IF NOT EXISTS passkey_credentials ( + id TEXT PRIMARY KEY, label TEXT NOT NULL, credential TEXT NOT NULL)", + )?; + Ok(Self { + db, + webauthn, + registrations: HashMap::new(), + assertions: HashMap::new(), + }) + } + + /// Database errors leave the manager locked. + pub fn is_unlocked(&self) -> bool { + self.db + .query_row( + "SELECT NOT EXISTS(SELECT 1 FROM passkey_credentials)", + [], + |row| row.get(0), + ) + .unwrap_or(false) + } + + fn credentials(&self) -> Result> { + let mut stmt = self + .db + .prepare("SELECT credential FROM passkey_credentials")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + rows.map(|row| Ok(serde_json::from_str(&row?)?)).collect() + } + + pub fn start_registration( + &mut self, + label: String, + gate: Option, + ) -> Result> { + let gated = gate.is_some(); + match gate { + None if !self.is_unlocked() => bail!("enrollment assertion required"), + None => {} + Some(Gate { id, response }) => { + let (_, binding) = self.finish_assertion(&id, response)?; + if binding + != (Binding::Enrollment { + label: label.clone(), + }) + { + bail!("invalid enrollment gate"); + } + } + } + let exclude = self + .credentials()? + .iter() + .map(|p| p.cred_id().clone()) + .collect(); + let (options, state) = self.webauthn.start_passkey_registration( + Uuid::new_v4(), + &label, + &label, + Some(exclude), + )?; + self.registrations + .retain(|_, p| p.deadline > Instant::now()); + let id = Uuid::new_v4().to_string(); + self.registrations.insert( + id.clone(), + Pending { + deadline: Instant::now() + Duration::from_secs(300), + value: (label, gated, state), + }, + ); + Ok(Challenge { id, options }) + } + + pub fn finish_registration( + &mut self, + id: &str, + response: RegisterPublicKeyCredential, + ) -> Result { + let (label, gated, state) = take(&mut self.registrations, id)?; + if !gated && !self.is_unlocked() { + bail!("enrollment assertion required"); + } + let credential = self + .webauthn + .finish_passkey_registration(&response, &state)?; + self.db.execute( + "INSERT INTO passkey_credentials (id, label, credential) VALUES (?1, ?2, ?3)", + params![ + serde_json::to_string(credential.cred_id())?, + label, + serde_json::to_string(&credential)? + ], + )?; + Ok(credential) + } + + pub fn start_assertion( + &mut self, + binding: Binding, + ) -> Result> { + let (options, state) = self + .webauthn + .start_passkey_authentication(&self.credentials()?)?; + self.assertions.retain(|_, p| p.deadline > Instant::now()); + let id = Uuid::new_v4().to_string(); + self.assertions.insert( + id.clone(), + Pending { + deadline: Instant::now() + Duration::from_secs(300), + value: (binding, state), + }, + ); + Ok(Challenge { id, options }) + } + + pub fn finish_assertion( + &mut self, + id: &str, + response: PublicKeyCredential, + ) -> Result<(CredentialID, Binding)> { + let (binding, state) = take(&mut self.assertions, id)?; + let result = self + .webauthn + .finish_passkey_authentication(&response, &state)?; + let mut credentials = self.credentials()?; + let credential = credentials + .iter_mut() + .find(|p| p.cred_id() == result.cred_id()) + .ok_or_else(|| anyhow::anyhow!("unregistered passkey"))?; + // Check the latest stored counter too. Another outstanding assertion may + // contain an older snapshot of this credential. + let stored = serde_json::to_value(&*credential)?; + let counter = stored["cred"]["counter"] + .as_u64() + .ok_or_else(|| anyhow::anyhow!("invalid stored passkey counter"))?; + if (counter > 0 || result.counter() > 0) && u64::from(result.counter()) <= counter { + bail!("passkey counter did not increase"); + } + credential.update_credential(&result); + self.db.execute( + "UPDATE passkey_credentials SET credential = ?1 WHERE id = ?2", + params![ + serde_json::to_string(credential)?, + serde_json::to_string(result.cred_id())? + ], + )?; + Ok((result.cred_id().clone(), binding)) + } +} + +fn take(map: &mut HashMap>, id: &str) -> Result { + let pending = map + .remove(id) + .ok_or_else(|| anyhow::anyhow!("unknown or consumed challenge"))?; + if pending.deadline <= Instant::now() { + bail!("expired challenge"); + } + Ok(pending.value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expired_assertion() -> Result<()> { + use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; + let db = Connection::open_in_memory()?; + let mut host = Passkeys::new(&db, "localhost", "http://localhost:8000")?; + let start = host.start_registration("test".into(), None)?; + let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let origin = Url::parse("http://localhost:8000")?; + let response = auth.do_registration(origin.clone(), start.options)?; + host.finish_registration(&start.id, response)?; + let start = host.start_assertion(Binding::Enrollment { + label: "next".into(), + })?; + let response = auth.do_authentication(origin, start.options)?; + host.assertions.get_mut(&start.id).unwrap().deadline = Instant::now(); + assert!(host.finish_assertion(&start.id, response).is_err()); + assert!(!host.assertions.contains_key(&start.id)); + Ok(()) + } +} diff --git a/host/src/harness/credentials.rs b/host/src/harness/credentials.rs new file mode 100644 index 0000000..6799285 --- /dev/null +++ b/host/src/harness/credentials.rs @@ -0,0 +1,162 @@ +//! Credential request snapshots and host-only model proxy configuration. +use crate::cred::{store::Cred, types::Subject}; +use deno_core::{OpState, op2}; +use deno_error::JsErrorBox; +use serde_json::{Value, json}; +use std::{cell::RefCell, rc::Rc, sync::Arc}; + +pub(super) struct Notifications { + filed: tokio::sync::Mutex>, +} +impl Notifications { + pub(super) fn new(cred: &Cred) -> Rc { + Rc::new(Self { + filed: tokio::sync::Mutex::new(cred.subscribe_filed()), + }) + } +} +#[op2] +pub(super) async fn op_credential_next(state: Rc>) { + let notifications = state.borrow().borrow::>().clone(); + let mut filed = notifications.filed.lock().await; + let _ = tokio::time::timeout(std::time::Duration::from_secs(1), filed.recv()).await; +} + +#[op2] +#[serde] +pub(super) fn op_credential_requests(state: &mut OpState) -> Result { + let cred = state.borrow::>(); + let work = || -> anyhow::Result { + cred.expire_requests()?; + // TODO(T27): prune retained records once a day after the library adds Cred::prune. + let mut rows = Vec::new(); + for request in cred.request_list()? { + let Subject::Conversation(conversation) = request.subject else { + continue; + }; + let names = request + .payload + .get("items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(|id| { + cred.item_get(id) + .map(|item| format!("`{}`", item.name)) + .unwrap_or_else(|_| "the credential".into()) + }) + .collect::>() + .join(", ") + }) + .unwrap_or_else(|| "the credential change".into()); + let message = match request.state.as_str() { + "approved" => format!("Your request for {names} was approved. Retry the command."), + "denied" => format!("Your request for {names} was denied."), + "expired" => format!( + "Your request for {names} expired. File a new request if you still need it." + ), + _ => String::new(), + }; + rows.push(json!({"id":request.id,"conversation":conversation,"state":request.state,"message":message})); + } + Ok(json!(rows)) + }; + work().map_err(|_| JsErrorBox::generic("Cannot read credential requests")) +} + +pub(super) fn proxy(cred: &Cred) -> anyhow::Result> { + use crate::cred::{ + items::{Field, Item}, + types::{Caller, FieldType}, + }; + let caller = Caller { + pid: 0, + program: "host".into(), + conversations: vec![], + }; + let item = cred + .item_list()? + .into_iter() + .find(|item| item.name == "llm-proxy"); + let item = match item { + Some(item) => item, + None => { + let (Ok(base), Ok(key)) = ( + std::env::var("PI_PROXY_BASE_URL"), + std::env::var("PI_PROXY_API_KEY"), + ) else { + return Ok(None); + }; + let mut store = cred.access()?; + let master = store.master.clone(); + let tx = store + .db + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let mut item = crate::cred::items::insert_new( + &tx, + Item { + id: String::new(), + name: "llm-proxy".into(), + version: 0, + free_use: false, + rules: vec![], + validate_js: None, + transform_js: "export default (ctx) => ctx.request".into(), + fields: vec![ + Field { + field_id: String::new(), + name: "base_url".into(), + public: true, + value: Some(base), + needs_value: false, + field_type: FieldType::Text, + key_type: None, + }, + Field { + field_id: String::new(), + name: "api_key".into(), + public: false, + value: None, + needs_value: true, + field_type: FieldType::Text, + key_type: None, + }, + ], + }, + &caller, + Some("host bootstrap"), + )?; + let field = &item + .fields + .iter() + .find(|f| f.name == "api_key") + .unwrap() + .field_id; + let sealed = + crate::cred::seal::seal(&master, &format!("{}|{field}", item.id), key.as_bytes())?; + tx.execute( + "INSERT INTO fields VALUES(?1,?2,?3)", + rusqlite::params![item.id, field, sealed], + )?; + item.fields + .iter_mut() + .find(|f| f.name == "api_key") + .unwrap() + .needs_value = false; + crate::cred::items::save(&tx, &item)?; + tx.commit()?; + item + } + }; + Ok(Some(cred.credential(&item, true)?)) +} + +#[op2] +#[serde] +pub(super) fn op_model_proxy(state: &mut OpState) -> Result { + proxy(state.borrow::>()) + .map(|v| v.unwrap_or(Value::Null)) + .map_err(|_| JsErrorBox::generic("Cannot read llm-proxy credential")) +} diff --git a/host/src/harness/mod.rs b/host/src/harness/mod.rs index 357b9e4..1a0efc5 100644 --- a/host/src/harness/mod.rs +++ b/host/src/harness/mod.rs @@ -1,4 +1,5 @@ //! Durable V8 harness, host-side SQLite storage, and world tool operations. +mod credentials; mod environment; use crate::{exec::Exec, world::World}; use deno_core::{JsRuntime, OpState, RuntimeOptions, op2}; @@ -78,6 +79,9 @@ deno_core::extension!( storage, ops = [ op_sql, + credentials::op_credential_requests, + credentials::op_model_proxy, + credentials::op_credential_next, environment::op_file, environment::op_exec, environment::op_exec_next, @@ -112,6 +116,18 @@ pub async fn run_with_exec(world: World, exec: Arc) -> anyhow::Result< /// Evaluate a bundled harness module with the same storage and tool operations as the console. /// The caller must use a current-thread Tokio runtime. pub async fn run_module(world: World, exec: Arc, source: &str) -> anyhow::Result<()> { + let cred = Arc::new(crate::cred::store::Cred::open(&world.host_dir)?); + run_module_with_credentials(world, exec, cred, source).await +} + +/// Run a harness with the credentials handle shared by the host API. +pub async fn run_module_with_credentials( + world: World, + exec: Arc, + cred: Arc, + source: &str, +) -> anyhow::Result<()> { + let proxy = credentials::proxy(&cred)?; let mut runtime = JsRuntime::new(RuntimeOptions { extensions: vec![ deno_telemetry::deno_telemetry::init(), @@ -162,11 +178,11 @@ pub async fn run_module(world: World, exec: Arc, source: &str) -> anyh deno_permissions::RuntimePermissionDescriptorParser::new(sys_traits::impls::RealSys), ))); runtime.execute_script("bootstrap.js", include_str!("../../js/bootstrap.js"))?; - let env: serde_json::Map = ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"] - .into_iter() - .filter_map(|key| std::env::var(key).ok().map(|v| (key.to_owned(), json!(v)))) - .collect(); - runtime.execute_script("env.js", format!("process.env = {};", Value::Object(env)))?; + runtime + .op_state() + .borrow_mut() + .put(credentials::Notifications::new(&cred)); + runtime.op_state().borrow_mut().put(cred); runtime .op_state() .borrow_mut() @@ -186,11 +202,26 @@ pub async fn run_module(world: World, exec: Arc, source: &str) -> anyh .load_main_es_module_from_code(&specifier, source.to_owned()) .await?; let result = runtime.mod_evaluate(module); - runtime - .with_event_loop_promise(result, Default::default()) - .await?; - runtime.run_event_loop(Default::default()).await?; - Ok(()) + let result = async { + runtime + .with_event_loop_promise(result, Default::default()) + .await?; + runtime.run_event_loop(Default::default()).await?; + Ok::<_, anyhow::Error>(()) + } + .await; + result.map_err(|error| { + let mut text = error.to_string(); + if let Some(key) = proxy + .as_ref() + .and_then(|v| v.get("api_key")) + .and_then(Value::as_str) + && !key.is_empty() + { + text = text.replace(key, "[redacted]"); + } + anyhow::anyhow!(text) + }) } /// Open a world and resume its durable root conversation. diff --git a/host/src/main.rs b/host/src/main.rs index 44866a0..879e293 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -5,6 +5,9 @@ use std::path::PathBuf; #[derive(Parser)] #[command(version, about)] struct Cli { + /// Shared host-user compile cache (otherwise SAUSAGE_CACHE or the XDG cache folder). + #[arg(long, global = true)] + cache: Option, #[command(subcommand)] command: Command, } @@ -29,7 +32,11 @@ enum Command { #[tokio::main] async fn main() -> anyhow::Result<()> { - match Cli::parse().command { + let cli = Cli::parse(); + if let Some(cache) = cli.cache { + sausage::process::configure_cache(cache)?; + } + match cli.command { Command::Run { world, userland } => { let bundle = userland.or_else(|| std::env::var_os("SAUSAGE_USERLAND").map(PathBuf::from)); diff --git a/host/src/process/cache.rs b/host/src/process/cache.rs new file mode 100644 index 0000000..df70a73 --- /dev/null +++ b/host/src/process/cache.rs @@ -0,0 +1,236 @@ +//! Shares compiled programs and single-flight compilation across worlds in one host process. +use super::{Compiled, State, adapter, bindings}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::{Arc, Mutex, OnceLock}, +}; +use tokio::sync::watch; +type Compilation = watch::Receiver>>; +use wasmtime::{ + Engine, + component::{Component, Linker}, +}; +use wasmtime_wasi::p2::bindings::CommandPre; + +static DIRECTORY: OnceLock = OnceLock::new(); +static SHARED: Mutex>> = Mutex::new(None); + +/// Select the host user's cache before opening a world. +pub fn configure(directory: PathBuf) -> anyhow::Result<()> { + DIRECTORY + .set(directory) + .map_err(|_| anyhow::anyhow!("cache directory already configured")) +} +fn directory() -> anyhow::Result { + if let Some(dir) = DIRECTORY.get() { + return Ok(dir.clone()); + } + if let Some(dir) = std::env::var_os("SAUSAGE_CACHE") { + return Ok(dir.into()); + } + if let Some(dir) = std::env::var_os("XDG_CACHE_HOME") { + return Ok(PathBuf::from(dir).join("sausage")); + } + Ok( + PathBuf::from(std::env::var_os("HOME").ok_or_else(|| anyhow::anyhow!("HOME is not set"))?) + .join(".cache/sausage"), + ) +} +pub(super) fn shared() -> anyhow::Result> { + let mut shared = SHARED.lock().unwrap(); + if shared.is_none() { + *shared = Some(Compiler::new(&directory()?)?); + } + Ok(shared.as_ref().unwrap().clone()) +} +pub(super) struct Compiler { + pub engine: Engine, + programs: Mutex, Compilation>>, + #[cfg(test)] + before_compile: Mutex>>, + #[cfg(test)] + compilations: std::sync::atomic::AtomicUsize, +} +impl Compiler { + pub fn new(directory: &Path) -> anyhow::Result> { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let mut config = wasmtime::Config::new(); + config.epoch_interruption(true); + // Keep Cranelift's default optimization: compiles are cached per content, and guests run + // many times. OptLevel::None saved only 12.7% of compile time (T15) but slows every run. + let mut cache = wasmtime::CacheConfig::new(); + cache.with_directory(directory); + config.cache(Some(wasmtime::Cache::new(cache)?)); + Ok(Arc::new(Self { + engine: Engine::new(&config)?, + programs: Mutex::new(HashMap::new()), + #[cfg(test)] + before_compile: Mutex::new(None), + #[cfg(test)] + compilations: std::sync::atomic::AtomicUsize::new(0), + })) + } + pub async fn get(self: &Arc, bytes: Vec) -> anyhow::Result { + let mut result = self + .programs + .lock() + .unwrap() + .entry(bytes.clone()) + .or_insert_with(|| { + let (tx, rx) = watch::channel(None); + let compiler = self.clone(); + // The job owns completion even if a command or a precompile waiter is cancelled. + tokio::spawn(async move { + let result = + tokio::task::spawn_blocking(move || compiler.compile(&bytes)).await; + tx.send_replace(Some( + result + .map_err(anyhow::Error::from) + .and_then(|r| r) + .map_err(|e| format!("{e:#}")), + )); + }); + rx + }) + .clone(); + loop { + if let Some(result) = result.borrow().clone() { + return result.map_err(|e| anyhow::anyhow!(e)); + } + result.changed().await?; + } + } + fn compile(&self, bytes: &[u8]) -> anyhow::Result { + #[cfg(test)] + { + self.compilations + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if let Some(hook) = self.before_compile.lock().unwrap().take() { + hook(); + } + } + if bytes.get(..8) == Some(b"\0asm\x01\0\0\0") { + let module = wasmtime::Module::new(&self.engine, bytes)?; + let mut linker = wasmtime::Linker::new(&self.engine); + sausage_vfs::preview1::add_to_linker(&mut linker, |s: &mut State| { + s.preview1.as_mut().unwrap() + })?; + Ok(Compiled::Module(linker.instantiate_pre(&module)?)) + } else { + let component = Component::new(&self.engine, bytes)?; + let mut linker = Linker::new(&self.engine); + wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; + wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; + adapter::add_to_linker(&mut linker, |s: &mut State| adapter::View { + adapter: &mut s.fs, + table: &mut s.table, + })?; + bindings::sausage::process::host::add_to_linker::<_, wasmtime::component::HasSelf<_>>( + &mut linker, + |s| s, + )?; + Ok(Compiled::Component(CommandPre::new( + linker.instantiate_pre(&component)?, + )?)) + } + } +} +/// Compile programs in both command folders without blocking world startup. +pub(super) fn precompile( + compiler: Arc, + root: Arc, +) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + let mut tasks = tokio::task::JoinSet::new(); + // Leave CPU and file bandwidth for commands during startup. + let slots = Arc::new(tokio::sync::Semaphore::new(2)); + for folder in ["/bin", "/usr/local/bin"] { + let entries = match root.list(folder).await { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e.into()), + }; + for entry in entries.into_iter().filter(|entry| !entry.directory) { + let root = root.clone(); + let compiler = compiler.clone(); + let path = format!("{folder}/{}", entry.name); + let slots = slots.clone(); + tasks.spawn(async move { + let _permit = slots.acquire().await?; + let file = root + .open( + &path, + sausage_vfs::Open { + follow: true, + ..Default::default() + }, + ) + .await?; + let bytes = file.read(0, file.stat().await?.size as usize).await?; + if bytes.get(..4) != Some(b"\0asm") { + return Ok::<_, anyhow::Error>(false); + } + compiler.get(bytes).await?; + Ok(true) + }); + } + } + let mut count = 0; + while let Some(result) = tasks.join_next().await { + if result?? { + count += 1; + } + } + Ok(count) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::exec::{Command, Exec, Options}; + use std::{future::Future, sync::atomic::Ordering}; + + #[tokio::test(flavor = "multi_thread")] + async fn command_joins_precompile_even_after_waiter_cancellation() { + let temp = tempfile::tempdir().unwrap(); + let compiler = Compiler::new(&temp.path().join("cache")).unwrap(); + let world = crate::world::World::open(&temp.path().join("world")).unwrap(); + let bytes = + wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#) + .unwrap(); + std::fs::create_dir_all(temp.path().join("world/world/bin")).unwrap(); + std::fs::write(temp.path().join("world/world/bin/program.wasm"), &bytes).unwrap(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (finish_tx, finish_rx) = std::sync::mpsc::channel(); + *compiler.before_compile.lock().unwrap() = Some(Box::new(move || { + started_tx.send(()).unwrap(); + finish_rx.recv().unwrap(); + })); + // Startup returns while the compiler remains blocked behind the test gate. + let runtime = super::super::Runtime::with_compiler(world, compiler.clone()).unwrap(); + started_rx.await.unwrap(); + let background = compiler.clone(); + let precompile = tokio::spawn(async move { background.get(bytes).await }); + let mut output = |_: crate::exec::Stream, _: &[u8]| {}; + let command = runtime.exec( + Command::Argv(vec!["/bin/program".into()]), + Options::default(), + &mut output, + ); + tokio::pin!(command); + let pending = std::future::poll_fn(|cx| { + std::task::Poll::Ready(command.as_mut().poll(cx).is_pending()) + }) + .await; + assert!(pending); + assert_eq!(compiler.compilations.load(Ordering::Relaxed), 1); + precompile.abort(); + let _ = precompile.await; + finish_tx.send(()).unwrap(); + assert_eq!(command.await.unwrap(), 0); + assert_eq!(compiler.compilations.load(Ordering::Relaxed), 1); + assert!(!temp.path().join("world/host/compile-cache").exists()); + } +} diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index 04858c5..c5bcdae 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -5,7 +5,7 @@ use crate::{ }; use sausage_vfs::{ Mounts, - adapter::{self, Adapter, View}, + adapter::{self, Adapter}, }; use std::{ collections::HashMap, @@ -22,7 +22,7 @@ use tokio::{ }; use wasmtime::{ Engine, Store, - component::{Component, Linker, Resource, ResourceTable}, + component::{Resource, ResourceTable}, }; use wasmtime_wasi::{ WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView, @@ -46,6 +46,8 @@ mod bindings { }); } use bindings::sausage::process::host::{Host, HostChild, Request, Stdio}; +mod cache; +pub use cache::configure as configure_cache; mod services; mod watcher; pub use watcher::Watcher; @@ -80,7 +82,8 @@ struct Entry { pub struct Runtime { world: World, engine: Engine, - cache: Mutex, Compiled>>, + compiler: Arc, + cache: Mutex>>, entries: Mutex>, next: AtomicU32, alive: Arc, @@ -141,14 +144,14 @@ impl Runtime { Self::new(World::open(path)?) } pub fn new(world: World) -> anyhow::Result> { - // Deno and WASI HTTP enable different providers. Select one before HTTPS creates a client. - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let mut config = wasmtime::Config::new(); - config.epoch_interruption(true); - let mut cache = wasmtime::CacheConfig::new(); - cache.with_directory(world.host_dir.join("compile-cache")); - config.cache(Some(wasmtime::Cache::new(cache)?)); - let engine = Engine::new(&config)?; + Self::with_compiler(world, cache::shared()?) + } + /// Open an independent engine using an explicit shared disk cache. + pub fn new_with_cache(world: World, directory: &Path) -> anyhow::Result> { + Self::with_compiler(world, cache::Compiler::new(directory)?) + } + fn with_compiler(world: World, compiler: Arc) -> anyhow::Result> { + let engine = compiler.engine.clone(); let alive = Arc::new(AtomicBool::new(true)); let ticking = alive.clone(); let clock = engine.clone(); @@ -158,19 +161,57 @@ impl Runtime { clock.increment_epoch(); } }); - Ok(Arc::new(Self { + let runtime = Arc::new(Self { world, engine, alive, - cache: Mutex::new(HashMap::new()), + compiler, + cache: Mutex::new(Default::default()), entries: Mutex::new(HashMap::new()), next: AtomicU32::new(1), - })) + }); + cache::precompile(runtime.compiler.clone(), runtime.world.root.clone()); + Ok(runtime) } /// Number of distinct program contents compiled by this runtime. pub fn compiled_programs(&self) -> usize { self.cache.lock().unwrap().len() } + async fn resolve_program( + &self, + cwd: &str, + name: &str, + env: &[(String, String)], + ) -> anyhow::Result { + let paths = if name.contains('/') { + vec![sausage_vfs::resolve(cwd, name)] + } else { + let path = env + .iter() + .find(|(key, _)| key == "PATH") + .map(|(_, value)| value.as_str()) + .unwrap_or("/usr/local/bin:/bin"); + path.split(':') + .map(|folder| { + sausage_vfs::resolve( + cwd, + &std::path::Path::new(folder).join(name).to_string_lossy(), + ) + }) + .collect() + }; + for path in paths { + for candidate in [path.clone(), format!("{path}.wasm")] { + match self.world.root.stat(&candidate, true).await { + Ok(stat) if !stat.directory => return Ok(candidate), + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + } + anyhow::bail!("program not found: {name}") + } fn kill_command(&self, command: u32) { for entry in self.entries.lock().unwrap().values() { if entry.command == command { @@ -225,7 +266,9 @@ impl Runtime { request.cwd = sausage_vfs::resolve("/", &request.cwd); request.env.retain(|(name, _)| name != "PWD"); request.env.push(("PWD".into(), request.cwd.clone())); - let executable = sausage_vfs::resolve(&request.cwd, &request.program); + let executable = self + .resolve_program(&request.cwd, &request.program, &request.env) + .await?; let file = self .world .root @@ -240,38 +283,8 @@ impl Runtime { let bytes = file .read(0, usize::try_from(file.stat().await?.size)?) .await?; - let pre = { - let mut cache = self.cache.lock().unwrap(); - if let Some(pre) = cache.get(&bytes) { - pre.clone() - } else { - let pre = if bytes.get(..8) == Some(b"\0asm\x01\0\0\0") { - let module = wasmtime::Module::new(&self.engine, &bytes)?; - let mut linker = wasmtime::Linker::new(&self.engine); - sausage_vfs::preview1::add_to_linker(&mut linker, |s: &mut State| { - s.preview1.as_mut().unwrap() - })?; - Compiled::Module(linker.instantiate_pre(&module)?) - } else { - let component = Component::new(&self.engine, &bytes)?; - let mut linker = Linker::new(&self.engine); - wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; - // The library linker includes exit-with-code. Only Wasmtime's CLI gates it. - wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; - adapter::add_to_linker(&mut linker, |s: &mut State| View { - adapter: &mut s.fs, - table: &mut s.table, - })?; - bindings::sausage::process::host::add_to_linker::< - _, - wasmtime::component::HasSelf<_>, - >(&mut linker, |s| s)?; - Compiled::Component(CommandPre::new(linker.instantiate_pre(&component)?)?) - }; - cache.insert(bytes, pre.clone()); - pre - } - }; + let pre = self.compiler.get(bytes.clone()).await?; + self.cache.lock().unwrap().insert(bytes); let pid = self.next.fetch_add(1, Ordering::Relaxed); let command = if command == 0 { pid } else { command }; let group = match request.group { diff --git a/host/src/world.rs b/host/src/world.rs index 0185b1f..b77674e 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -19,17 +19,37 @@ pub struct World { pub processes: crate::process::Processes, } +fn copy_tree(source: &Path, destination: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(destination)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let target = destination.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_tree(&entry.path(), &target)?; + } else { + std::fs::copy(entry.path(), target)?; + } + } + Ok(()) +} + impl World { /// Open (and create if missing) the world directory `dir`, including `/proc` and `/run` mounts. pub fn open(dir: &Path) -> anyhow::Result { Self::open_with_userland(dir, None) } - /// Mount an optional host bundle read-only at `/bin` (D43). + /// Copy an optional bundle into a new world. Existing worlds retain their programs. pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result { let root_dir = dir.join("world"); let host_dir = dir.join("host"); + let fresh = !root_dir.exists(); std::fs::create_dir_all(root_dir.join("usr/local/bin"))?; + if fresh && let Some(bundle) = userland { + for folder in ["bin", "src"] { + copy_tree(&bundle.join(folder), &root_dir.join(folder))?; + } + } std::fs::create_dir_all(&host_dir)?; let mut mounts = Mounts::default(); mounts.mount( @@ -55,17 +75,11 @@ impl World { } Ok(text.into_bytes()) }); - if let Some(bundle) = userland { - mounts.mount( - "/bin", - Arc::new(Host::new(bundle, Process::default())?), - true, - ); - } mounts.mount("/proc", Arc::new(proc), true); mounts.mount("/run", Arc::new(Memory::default()), false); + let root: Arc = Arc::new(mounts); Ok(World { - root: Arc::new(mounts), + root, host_dir, processes, }) diff --git a/host/tests/cred.rs b/host/tests/cred.rs new file mode 100644 index 0000000..450337f --- /dev/null +++ b/host/tests/cred.rs @@ -0,0 +1,255 @@ +//! Credential core acceptance tests with a script fake and host-only temporary storage. +use sausage::cred::{ + grants::Grant, + inject::Outcome, + items::*, + requests::{GrantRequest, Overrides}, + script::{self, Limits, Script}, + *, +}; +fn caller(id: &str) -> Caller { + Caller { + program: "/bin/tool.wasm".into(), + pid: 42, + conversations: vec![id.into()], + } +} +fn item(free_use: bool) -> Item { + Item { + id: String::new(), + name: "token".into(), + version: 0, + free_use, + rules: vec![Rule { + scheme: "https".into(), + hosts: vec!["api.example".into()], + path_prefixes: vec!["/".into()], + methods: None, + }], + validate_js: None, + transform_js: String::new(), + fields: vec![Field { + field_id: String::new(), + name: "token".into(), + public: false, + value: None, + needs_value: true, + field_type: types::FieldType::Text, + key_type: None, + }], + } +} +fn parts(host: &str) -> http::request::Parts { + http::Request::get(format!("https://{host}/path")) + .body(()) + .unwrap() + .into_parts() + .0 +} +struct Fake; +impl Script for Fake { + fn evaluate( + &self, + source: &str, + input: &str, + _: Option<&str>, + _: Limits, + ) -> std::result::Result { + let ctx: serde_json::Value = serde_json::from_str(input).unwrap(); + match source { + "deny"=>Ok("false".into()), + "validate"=>{assert!(ctx["credential"].get("token").is_none());Ok("true".into())}, + "throw"=>Err(script::Error{kind:script::ErrorKind::Throw,message:ctx["credential"]["token"].as_str().unwrap().into()}), + "escape"=>Ok(r#"{"url":"https://evil.example/"}"#.into()), + _=>Ok(serde_json::json!({"headers":[{"name":"authorization","value":ctx["credential"]["token"]}]}).to_string()) + } + } +} +#[test] +fn inject_selection_idempotency_and_subtree() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let root = caller("root"); + assert!(matches!( + c.inject(parts("api.example"), &root, &Fake).unwrap(), + Outcome::Forward(_) + )); + let i = c.create_item(item(false), &root, "passkey").unwrap(); + let first = match c.inject(parts("api.example"), &root, &Fake).unwrap() { + Outcome::Denied { item, request } => { + assert_eq!(item, "token"); + request + } + _ => panic!("expected denial"), + }; + let second = match c.inject(parts("api.example"), &root, &Fake).unwrap() { + Outcome::Denied { request, .. } => request, + _ => panic!("expected denial"), + }; + assert_eq!(first, second); + assert_eq!(c.request_list().unwrap().len(), 1); + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &root, "passkey") + .unwrap(); + c.approve(&first, "allow", Overrides::default(), Some("passkey")) + .unwrap(); + let child = Caller { + program: "/bin/tool.wasm".into(), + pid: 43, + conversations: vec!["child".into(), "root".into()], + }; + assert!(matches!( + c.inject(parts("api.example"), &child, &Fake).unwrap(), + Outcome::Forward(_) + )); + assert!(matches!( + c.inject(parts("api.example"), &caller("sibling"), &Fake) + .unwrap(), + Outcome::Denied { .. } + )); + let mut other = item(true); + other.name = "other".into(); + c.create_item(other, &root, "passkey").unwrap(); + assert_eq!( + c.inject(parts("api.example"), &root, &Fake) + .err() + .unwrap() + .0, + "ambiguous_item" + ); + assert_eq!(c.grant_list().unwrap()[0].items, vec![i.id]); +} +#[test] +fn free_use_validators_transform_recheck_and_redaction() { + for (source, validator, expected) in [ + ("inject", Some("validate"), None), + ("inject", Some("deny"), Some("validator_denied")), + ("escape", None, Some("rule_recheck")), + ("throw", None, Some("script")), + ] { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = caller("root"); + let mut config = item(true); + config.transform_js = source.into(); + config.validate_js = validator.map(Into::into); + let i = c.create_item(config, &caller, "passkey").unwrap(); + let secret = "SECRET_SENTINEL_19"; + c.set_field(&i.id, &i.fields[0].field_id, secret, &caller, "passkey") + .unwrap(); + let result = c.inject(parts("api.example"), &caller, &Fake); + if let Some(expected) = expected { + let error = result.err().unwrap(); + assert_eq!(error.0, expected); + assert!(!format!("{error:?}").contains(secret)); + } else { + let Outcome::Forward(p) = result.unwrap() else { + panic!("denied") + }; + assert!(p.headers["authorization"] == secret); + } + c.audit_sign(&i.id, &caller, secret.as_bytes()).unwrap(); + assert!(!format!("{:?}", c.item_get(&i.id).unwrap()).contains(secret)); + assert!( + !serde_json::to_string(&c.audit_list().unwrap()) + .unwrap() + .contains(secret) + ); + let id = c + .file_request( + &caller, + "review", + GrantRequest { + items: vec![i.id.clone()], + duration_secs: None, + max_uses: None, + validator: None, + }, + ) + .unwrap(); + assert!(!format!("{:?}", c.request_diff(&id).unwrap()).contains(secret)); + } +} +#[test] +fn atomic_budget_and_decision_notification() { + let dir = tempfile::tempdir().unwrap(); + let c = std::sync::Arc::new(Cred::open(dir.path()).unwrap()); + let caller = caller("root"); + let i = c.create_item(item(false), &caller, "passkey").unwrap(); + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "passkey") + .unwrap(); + c.grant_create( + Grant { + id: String::new(), + items: vec![i.id], + subject: types::Subject::Conversation("root".into()), + exp: None, + max_uses: Some(1), + uses: 0, + revoked: false, + validator: None, + note: None, + }, + &caller, + Some("passkey"), + ) + .unwrap(); + let handles: Vec<_> = (0..2) + .map(|_| { + let c = c.clone(); + let caller = caller.clone(); + std::thread::spawn(move || { + matches!( + c.inject(parts("api.example"), &caller, &Fake).unwrap(), + Outcome::Forward(_) + ) + }) + }) + .collect(); + assert_eq!( + handles + .into_iter() + .map(|h| usize::from(h.join().unwrap())) + .sum::(), + 1 + ); + let id = c.request_list().unwrap()[0].id.clone(); + let mut receiver = c.subscribe(); + c.deny(&id, "no", Some("passkey")).unwrap(); + assert_eq!(receiver.try_recv().unwrap(), id); + assert_eq!( + c.deny(&id, "again", Some("passkey")).unwrap_err().0, + "already_decided" + ); +} +#[test] +fn key_and_secret_persist() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = caller("root"); + let mut config = item(true); + config.transform_js = "inject".into(); + let i = c.create_item(config, &caller, "passkey").unwrap(); + c.set_field( + &i.id, + &i.fields[0].field_id, + "persistent", + &caller, + "passkey", + ) + .unwrap(); + drop(c); + assert_eq!( + std::fs::metadata(dir.path().join("cred.key")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + let c = Cred::open(dir.path()).unwrap(); + let Outcome::Forward(p) = c.inject(parts("api.example"), &caller, &Fake).unwrap() else { + panic!("denied") + }; + assert!(p.headers["authorization"] == "persistent"); +} diff --git a/host/tests/cred_core.rs b/host/tests/cred_core.rs new file mode 100644 index 0000000..a5d09a4 --- /dev/null +++ b/host/tests/cred_core.rs @@ -0,0 +1,205 @@ +//! Ported rule boundaries and request/edit ledger regressions. +use sausage::cred::{ + edits::Edit, + items::*, + requests::{GrantRequest, Overrides}, + *, +}; +fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 7, + conversations: vec!["root".into()], + }; + let i = c + .create_item( + Item { + id: String::new(), + name: "item".into(), + version: 0, + free_use: false, + rules: vec![], + validate_js: None, + transform_js: String::new(), + fields: vec![Field { + field_id: String::new(), + name: "secret".into(), + public: false, + value: None, + needs_value: true, + field_type: types::FieldType::Text, + key_type: None, + }], + }, + &caller, + "pk", + ) + .unwrap(); + (dir, c, caller, i) +} +#[test] +fn rule_matching_table() { + let mut rule:Rule=serde_json::from_value(serde_json::json!({"scheme":"https","hosts":["api.example.test","*.service.test"],"path_prefixes":["/v1/","/health"],"methods":["GET","POST"]})).unwrap(); + for (scheme, host, path, method, allowed) in [ + ("https", "api.example.test", "/v1/a", "GET", true), + ("HTTPS", "API.EXAMPLE.TEST", "/v1/a", "get", true), + ("https", "a.service.test", "/health", "POST", true), + ("https", "a.b.service.test", "/healthz", "POST", true), + ("https", "service.test", "/health", "GET", false), + ("https", "evilservice.test", "/health", "GET", false), + ("https", "a.service.test.evil", "/health", "GET", false), + ("https", "api.example.test.evil", "/v1/a", "GET", false), + ("http", "api.example.test", "/v1/a", "GET", false), + ("https", "api.example.test", "/V1/a", "GET", false), + ("https", "api.example.test", "/v1", "GET", false), + ("https", "api.example.test", "/v1/a", "DELETE", false), + ] { + assert_eq!(rule_matches(&rule, scheme, host, path, method), allowed); + } + rule.methods = None; + assert!(rule_matches( + &rule, + "https", + "api.example.test", + "/v1/a", + "DELETE" + )); + rule.methods = Some(vec![]); + assert!(!rule_matches( + &rule, + "https", + "api.example.test", + "/v1/a", + "GET" + )); + rule.methods = None; + rule.path_prefixes.clear(); + assert!(!rule_matches( + &rule, + "https", + "api.example.test", + "/v1/a", + "GET" + )); + rule.path_prefixes.push("/".into()); + rule.hosts.clear(); + assert!(!rule_matches( + &rule, + "https", + "api.example.test", + "/v1/a", + "GET" + )); +} +#[test] +fn revisions_needs_value_and_rename_are_metadata_only() { + let (_dir, c, caller, i) = fixture(); + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "pk") + .unwrap(); + let old = c.item_get(&i.id).unwrap(); + assert_eq!(old.version, 2); + assert!(!old.fields[0].needs_value); + assert!(old.fields[0].value.is_none()); + let mut new = old.clone(); + new.name = "renamed".into(); + new.fields[0].name = "renamed_field".into(); + let id = c + .file_edit_request( + &caller, + "rename", + Edit { + item: i.id.clone(), + base_version: 2, + config: new, + }, + ) + .unwrap(); + assert!(!c.edit_diff(&id).unwrap().discards_values); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let current = c.item_get(&i.id).unwrap(); + assert_eq!(current.version, 3); + assert_eq!(current.fields[0].field_id, old.fields[0].field_id); + assert_eq!(current.name, "renamed"); + assert_eq!(c.item_versions(&i.id).unwrap().len(), 2); +} +#[test] +fn stale_edits_leave_history_and_values_untouched() { + let (_dir, c, caller, i) = fixture(); + let id = c + .file_edit_request( + &caller, + "edit", + Edit { + item: i.id.clone(), + base_version: 1, + config: i.clone(), + }, + ) + .unwrap(); + c.set_field(&i.id, &i.fields[0].field_id, "opaque", &caller, "pk") + .unwrap(); + assert_eq!( + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap_err() + .0, + "stale" + ); + assert_eq!(c.request_list().unwrap()[0].state, "pending"); + assert_eq!(c.item_versions(&i.id).unwrap().len(), 1); +} +#[test] +fn key_conflict_renewal_and_expiry() { + let (dir, c, caller, i) = fixture(); + let p = GrantRequest { + items: vec![i.id.clone()], + duration_secs: None, + max_uses: None, + validator: None, + }; + let id = c.file_request(&caller, "key", p.clone()).unwrap(); + let mut other = p.clone(); + other.max_uses = Some(1); + assert_eq!( + c.file_request(&caller, "key", other).unwrap_err().0, + "key_conflict" + ); + c.deny(&id, "no", Some("pk")).unwrap(); + let next = c.file_request(&caller, "key", p).unwrap(); + assert_ne!(id, next); + assert_eq!(c.request_decisions().unwrap().len(), 1); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + db.execute("UPDATE requests SET expires=0 WHERE id=?", [&next]) + .unwrap(); + let mut notification = c.subscribe(); + assert_eq!(c.expire_requests().unwrap(), vec![next.clone()]); + assert_eq!(notification.try_recv().unwrap(), next); + assert_eq!( + c.approve(&next, "yes", Overrides::default(), Some("pk")) + .unwrap_err() + .0, + "request_expired" + ); + assert!(c.grant_list().unwrap().is_empty()); +} +#[test] +fn typed_values_and_secret_configuration_fail_without_writes() { + let (_dir, c, caller, i) = fixture(); + assert_eq!( + c.set_field(&i.id, &i.fields[0].field_id, "line\nbreak", &caller, "pk") + .unwrap_err() + .0, + "invalid_value" + ); + assert_eq!(c.item_get(&i.id).unwrap().version, 1); + let mut config = i.clone(); + config.fields[0].value = Some("forbidden".into()); + assert_eq!( + c.create_item(config, &caller, "pk").unwrap_err().0, + "secret_field" + ); + assert_eq!(c.item_list().unwrap().len(), 1); + assert!(c.item_versions(&i.id).unwrap().is_empty()); +} diff --git a/host/tests/cred_edits.rs b/host/tests/cred_edits.rs new file mode 100644 index 0000000..ab8beeb --- /dev/null +++ b/host/tests/cred_edits.rs @@ -0,0 +1,266 @@ +//! Explicit edit operations and atomic approval regression tests. +use sausage::cred::{ + edits::{Operation, OperationRequest}, + items::*, + requests::Overrides, + *, +}; +fn config(name: &str) -> Item { + Item { + id: String::new(), + name: name.into(), + version: 0, + free_use: false, + rules: vec![], + validate_js: None, + transform_js: String::new(), + fields: vec![Field { + field_id: String::new(), + name: "private".into(), + public: false, + value: None, + needs_value: true, + field_type: types::FieldType::SshPrivateKey, + key_type: Some("es256".into()), + }], + } +} +#[test] +fn creation_key_generation_and_deletion_keep_secrets_out_of_history() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 3, + conversations: vec!["root".into()], + }; + let id = c + .file_operation_request( + &caller, + "generate", + OperationRequest { + item: None, + base_version: None, + change: vec![Operation::GenerateEs256Item { + config: config("generated"), + }], + }, + ) + .unwrap(); + assert!(c.item_list().unwrap().is_empty()); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let item = c.item_list().unwrap().remove(0); + assert!(!item.fields[0].needs_value); + assert!(item.fields[0].value.is_none()); + assert!( + !serde_json::to_string(&c.request_diff(&id).unwrap()) + .unwrap() + .contains("PRIVATE KEY") + ); + let id = c + .file_operation_request( + &caller, + "delete", + OperationRequest { + item: Some(item.id.clone()), + base_version: Some(item.version), + change: vec![Operation::DeleteItem], + }, + ) + .unwrap(); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert!(c.item_list().unwrap().is_empty()); + assert_eq!(c.item_versions(&item.id).unwrap().len(), 1); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + assert_eq!( + db.query_row("SELECT COUNT(*) FROM fields", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 0 + ); +} +#[test] +fn operation_order_name_conflicts_and_stale_resolution() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 3, + conversations: vec!["root".into()], + }; + let item = c.create_item(config("first"), &caller, "pk").unwrap(); + c.create_item(config("taken"), &caller, "pk").unwrap(); + let request = |change| OperationRequest { + item: Some(item.id.clone()), + base_version: Some(item.version), + change, + }; + assert_eq!( + c.file_operation_request( + &caller, + "conflict", + request(vec![Operation::SetName { + name: "taken".into() + }]) + ) + .unwrap_err() + .0, + "name_conflict" + ); + let id = c + .file_operation_request( + &caller, + "rename", + request(vec![ + Operation::SetName { + name: "intermediate".into(), + }, + Operation::SetName { + name: "last".into(), + }, + ]), + ) + .unwrap(); + let other = c + .file_operation_request( + &caller, + "other", + request(vec![Operation::SetFreeUse { enabled: true }]), + ) + .unwrap(); + c.approve(&other, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert!(c.edit_diff(&id).unwrap().stale); + assert_eq!( + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap_err() + .0, + "stale" + ); + c.resolve_stale(&id, 2).unwrap(); + assert!(!c.edit_diff(&id).unwrap().stale); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let current = c.item_get(&item.id).unwrap(); + assert_eq!(current.name, "last"); + assert!(current.free_use); +} +#[test] +fn field_removal_revert_and_public_visibility() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 3, + conversations: vec!["root".into()], + }; + let mut initial = config("item"); + initial.fields[0].field_type = types::FieldType::Text; + initial.fields[0].key_type = None; + initial.fields.push(Field { + field_id: String::new(), + name: "public".into(), + public: true, + value: Some("first".into()), + needs_value: false, + field_type: types::FieldType::Text, + key_type: None, + }); + let item = c.create_item(initial, &caller, "pk").unwrap(); + c.set_field( + &item.id, + &item.fields[0].field_id, + "SECRET_REMOVED", + &caller, + "pk", + ) + .unwrap(); + let current = c.item_get(&item.id).unwrap(); + let request = |change| OperationRequest { + item: Some(item.id.clone()), + base_version: Some(current.version), + change, + }; + let id = c + .file_operation_request( + &caller, + "remove", + request(vec![ + Operation::RemoveField { + field: item.fields[0].field_id.clone(), + }, + Operation::SetFieldValue { + field: item.fields[1].field_id.clone(), + value: "second".into(), + }, + ]), + ) + .unwrap(); + assert!(c.edit_diff(&id).unwrap().discards_values); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let id = c + .file_revert_request(&caller, "revert", &item.id, 3, 2) + .unwrap(); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let restored = c.item_get(&item.id).unwrap(); + assert!(restored.fields[0].needs_value); + assert_eq!(restored.fields[0].field_id, item.fields[0].field_id); + assert_eq!(restored.fields[1].value.as_deref(), Some("second")); + let mut changed = restored.clone(); + changed.fields[1].public = false; + changed.fields[1].value = None; + assert_eq!( + c.file_edit_request( + &caller, + "visibility", + sausage::cred::edits::Edit { + item: item.id, + base_version: restored.version, + config: changed + } + ) + .unwrap_err() + .0, + "immutable_visibility" + ); + assert!(!format!("{:?}", c.item_list().unwrap()).contains("SECRET_REMOVED")); +} +#[test] +fn decision_failure_rolls_back_every_write() { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 3, + conversations: vec!["root".into()], + }; + let id = c + .file_operation_request( + &caller, + "create", + OperationRequest { + item: None, + base_version: None, + change: vec![Operation::CreateItem { + config: config("first"), + }], + }, + ) + .unwrap(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + db.execute_batch("CREATE TRIGGER fail_decision BEFORE UPDATE ON requests BEGIN SELECT RAISE(ABORT, 'forced'); END;").unwrap(); + assert!( + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .is_err() + ); + assert!(c.item_list().unwrap().is_empty()); + assert!(c.audit_list().unwrap().is_empty()); + assert_eq!(c.request_list().unwrap()[0].state, "pending"); + db.execute_batch("DROP TRIGGER fail_decision;").unwrap(); + c.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!(c.item_list().unwrap().len(), 1); +} diff --git a/host/tests/cred_entry.rs b/host/tests/cred_entry.rs new file mode 100644 index 0000000..12bb86c --- /dev/null +++ b/host/tests/cred_entry.rs @@ -0,0 +1,62 @@ +//! WebCrypto interoperability and host key persistence for secret entry. +use deno_core::{JsRuntime, RuntimeOptions}; +use sausage::cred::entry::{EntryKey, Envelope}; +use std::{os::unix::fs::PermissionsExt, sync::Arc}; + +#[tokio::test(flavor = "current_thread")] +async fn webcrypto_entry() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("entry.key"); + let key = EntryKey::open(&path)?; + assert_eq!( + std::fs::metadata(&path)?.permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(key.public_key(), EntryKey::open(&path)?.public_key()); + let mut runtime = JsRuntime::new(RuntimeOptions { + extensions: vec![ + deno_webidl::deno_webidl::init(), + deno_web::deno_web::init( + Arc::new(deno_web::BlobStore::default()), + None, + false, + Default::default(), + ), + deno_crypto::deno_crypto::init(None), + ], + ..Default::default() + }); + runtime.execute_script( + "crypto.js", + "globalThis.crypto = Deno.core.loadExtScript('ext:deno_crypto/00_crypto.js').crypto;", + )?; + runtime.execute_script("encrypt.js", format!(r#" + globalThis.done = (async () => {{ + const host = await crypto.subtle.importKey('raw', new Uint8Array({}), {{name:'ECDH', namedCurve:'P-256'}}, false, []); + const pair = await crypto.subtle.generateKey({{name:'ECDH', namedCurve:'P-256'}}, true, ['deriveBits']); + const bits = await crypto.subtle.deriveBits({{name:'ECDH', public:host}}, pair.privateKey, 256); + const material = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']); + const info = new Uint8Array(Array.from('sausage/secret-entry/v1', c => c.charCodeAt(0))); + const aes = await crypto.subtle.deriveKey({{name:'HKDF', hash:'SHA-256', salt:new Uint8Array(), info}}, material, {{name:'AES-GCM', length:256}}, false, ['encrypt']); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({{name:'AES-GCM', iv}}, aes, new Uint8Array(Array.from('browser-only-value', c => c.charCodeAt(0)))); + globalThis.envelope = JSON.stringify({{ephemeral_public_key:Array.from(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))), iv:Array.from(iv), ciphertext:Array.from(new Uint8Array(ciphertext))}}); + }})(); + "#, serde_json::to_string(&key.public_key())?))?; + runtime.run_event_loop(Default::default()).await?; + let value = runtime.execute_script("result.js", "envelope")?; + let text = { + deno_core::scope!(scope, &mut runtime); + let value = deno_core::v8::Local::new(scope, value); + value.to_rust_string_lossy(scope) + }; + let mut envelope: Envelope = serde_json::from_str(&text)?; + let expected = b"browser-only-value"; + assert!(key.decrypt(&envelope)? == expected); + let hash = envelope.sha256(); + envelope.ciphertext[0] ^= 1; + assert_ne!(hash, envelope.sha256()); + let error = key.decrypt(&envelope).unwrap_err().to_string(); + assert!(!error.contains(std::str::from_utf8(expected)?)); + Ok(()) +} diff --git a/host/tests/cred_grants.rs b/host/tests/cred_grants.rs new file mode 100644 index 0000000..b28d18a --- /dev/null +++ b/host/tests/cred_grants.rs @@ -0,0 +1,199 @@ +//! Grant expiry, revocation, validators and issuance boundaries from v2. +use sausage::cred::{ + grants::Grant, + inject::Outcome, + items::*, + script::{Limits, Script}, + *, +}; +struct Fake; +impl Script for Fake { + fn evaluate( + &self, + source: &str, + input: &str, + _: Option<&str>, + _: Limits, + ) -> std::result::Result { + let ctx: serde_json::Value = serde_json::from_str(input).unwrap(); + assert_eq!(ctx["grant"]["conversation"], "root"); + Ok(if source == "deny" { "false" } else { "true" }.into()) + } +} +fn parts() -> http::request::Parts { + http::Request::get("https://api.test/path") + .body(()) + .unwrap() + .into_parts() + .0 +} +fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { + let dir = tempfile::tempdir().unwrap(); + let c = Cred::open(dir.path()).unwrap(); + let caller = Caller { + program: "/bin/tool.wasm".into(), + pid: 1, + conversations: vec!["root".into()], + }; + let i = c + .create_item( + Item { + id: String::new(), + name: "item".into(), + version: 0, + free_use: false, + rules: vec![Rule { + scheme: "https".into(), + hosts: vec!["api.test".into()], + path_prefixes: vec!["/".into()], + methods: None, + }], + validate_js: None, + transform_js: String::new(), + fields: vec![], + }, + &caller, + "pk", + ) + .unwrap(); + (dir, c, caller, i) +} +fn grant(item: &Item) -> Grant { + Grant { + id: String::new(), + items: vec![item.id.clone()], + subject: types::Subject::Conversation("root".into()), + exp: None, + max_uses: Some(1), + uses: 0, + revoked: false, + validator: None, + note: Some("work".into()), + } +} +#[test] +fn program_grant_covers_service_and_agent_but_not_another_program() { + let (_dir, c, _caller, _item) = fixture(); + let service = Caller { + pid: 10, + program: "/bin/service.wasm".into(), + conversations: vec![], + }; + let Outcome::Denied { request, .. } = c.inject(parts(), &service, &Fake).unwrap() else { + panic!("expected request") + }; + assert_eq!( + c.request_list().unwrap()[0].subject, + types::Subject::Program(service.program.clone()) + ); + c.approve( + &request, + "allow", + sausage::cred::requests::Overrides::default(), + None, + ) + .unwrap(); + let agent = Caller { + pid: 11, + program: service.program.clone(), + conversations: vec!["agent".into()], + }; + for caller in [&service, &agent] { + assert!(matches!( + c.inject(parts(), caller, &Fake).unwrap(), + Outcome::Forward(_) + )); + } + let other = Caller { + pid: 12, + program: "/bin/other.wasm".into(), + conversations: vec![], + }; + assert!(matches!( + c.inject(parts(), &other, &Fake).unwrap(), + Outcome::Denied { .. } + )); + let decisions = c.audit_list().unwrap(); + let row = decisions.iter().find(|r| r.event == "approved").unwrap(); + assert!(row.approver.is_none()); + assert_eq!(row.program, service.program); + assert!(row.conversation.is_none()); +} +#[test] +fn expiry_boundary_revoke_and_exhaustion() { + let (dir, c, caller, i) = fixture(); + let g = c.grant_create(grant(&i), &caller, Some("pk")).unwrap(); + assert!(matches!( + c.inject(parts(), &caller, &Fake).unwrap(), + Outcome::Forward(_) + )); + assert_eq!(c.grant_status(&g.id).unwrap().uses, 1); + assert!(matches!( + c.inject(parts(), &caller, &Fake).unwrap(), + Outcome::Denied { .. } + )); + assert_eq!(c.grant_status(&g.id).unwrap().uses, 1); + let mut unlimited = grant(&i); + unlimited.max_uses = None; + let g = c.grant_create(unlimited, &caller, Some("pk")).unwrap(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + db.execute("UPDATE grants SET exp=0 WHERE id=?", [&g.id]) + .unwrap(); + assert!(matches!( + c.inject(parts(), &caller, &Fake).unwrap(), + Outcome::Denied { .. } + )); + assert_eq!(c.grant_status(&g.id).unwrap().uses, 0); + c.revoke_subjects(&caller.conversations, &caller, Some("pk")) + .unwrap(); + assert!(c.grant_list().unwrap().iter().all(|g| g.revoked)); +} +#[test] +fn reject_bad_issuance_without_rows() { + let (_dir, c, caller, i) = fixture(); + let mut g = grant(&i); + g.max_uses = Some(0); + assert_eq!( + c.grant_create(g, &caller, Some("pk")).unwrap_err().0, + "invalid_uses" + ); + let mut g = grant(&i); + g.subject = types::Subject::Conversation(String::new()); + assert_eq!( + c.grant_create(g, &caller, Some("pk")).unwrap_err().0, + "invalid_subject" + ); + assert!(c.grant_list().unwrap().is_empty()); + assert_eq!(c.audit_list().unwrap().len(), 1); +} +#[test] +fn preflight_spends_nothing_and_validator_denial_spends_one() { + let (_dir, c, caller, i) = fixture(); + let mut g = grant(&i); + g.validator = Some("deny".into()); + let g = c.grant_create(g, &caller, Some("pk")).unwrap(); + let mut p = parts(); + p.headers + .insert("bad", http::HeaderValue::from_bytes(&[0xff]).unwrap()); + assert_eq!( + c.inject(p, &caller, &Fake).err().unwrap().0, + "invalid_headers" + ); + assert_eq!(c.grant_status(&g.id).unwrap().uses, 0); + assert_eq!( + c.inject(parts(), &caller, &Fake).err().unwrap().0, + "validator_denied" + ); + assert_eq!(c.grant_status(&g.id).unwrap().uses, 1); + let audit = c.audit_list().unwrap(); + let use_row = audit.last().unwrap(); + assert_eq!(use_row.event, "fetch.denied"); + assert_eq!(use_row.pid, caller.pid); + assert_eq!(use_row.conversation.as_deref(), Some("root")); + assert!( + audit + .iter() + .filter(|r| r.event == "grant.create") + .all(|r| r.approver.as_deref() == Some("pk")) + ); +} diff --git a/host/tests/cred_oauth2.rs b/host/tests/cred_oauth2.rs new file mode 100644 index 0000000..81f52e8 --- /dev/null +++ b/host/tests/cred_oauth2.rs @@ -0,0 +1,100 @@ +//! Refresh form encoding, response validation, and revision-aware cache behavior. +use sausage::cred::oauth2::*; +use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; +struct Fake(AtomicUsize); +impl Http for Fake { + async fn send(&self, request: &Request, _: Instant) -> Result>, Error> { + assert_eq!(request.method, "POST"); + self.0.fetch_add(1, Ordering::SeqCst); + Ok(Zeroizing::new(br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":3600,"scope":"one two"}"#.to_vec())) + } +} +#[tokio::test] +async fn encoding_and_cache() { + let req = request( + "https://tokens.example.test/token", + "REFRESH&=+", + "CLIENT", + "SECRET&=+", + ) + .unwrap(); + assert!( + req.body.as_str() + == "grant_type=refresh_token&refresh_token=REFRESH%26%3D%2B&client_id=CLIENT&client_secret=SECRET%26%3D%2B" + ); + assert_eq!(req.headers[1].1, req.body.len().to_string()); + let fake = Fake(AtomicUsize::new(0)); + let mut cache = Cache::default(); + let deadline = Instant::now() + Duration::from_secs(1); + let t = cache.refresh("id", 1, &req, &fake, deadline).await.unwrap(); + assert_eq!(t.scopes.unwrap(), vec!["one", "two"]); + cache.refresh("id", 1, &req, &fake, deadline).await.unwrap(); + assert_eq!(fake.0.load(Ordering::SeqCst), 1); + cache.refresh("id", 2, &req, &fake, deadline).await.unwrap(); + assert_eq!(fake.0.load(Ordering::SeqCst), 2); + cache.clear(); + cache.refresh("id", 2, &req, &fake, deadline).await.unwrap(); + assert_eq!(fake.0.load(Ordering::SeqCst), 3); + for url in [ + "http://tokens.example.test/token", + "https://user@tokens.example.test/token", + "https://tokens.example.test/token#x", + ] { + assert!(request(url, "r", "c", "s").is_err()); + } +} +#[tokio::test] +async fn expiry_and_failure() { + struct Expired(AtomicUsize); + impl Http for Expired { + async fn send(&self, _: &Request, _: Instant) -> Result>, Error> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(Zeroizing::new( + br#"{"access_token":"SYNTHETIC","token_type":"Bearer","expires_in":1}"#.to_vec(), + )) + } + } + struct Failed; + impl Http for Failed { + async fn send(&self, _: &Request, _: Instant) -> Result>, Error> { + Err(Error) + } + } + let req = request("https://tokens.example.test/token", "r", "c", "s").unwrap(); + let mut cache = Cache::default(); + let http = Expired(AtomicUsize::new(0)); + let deadline = Instant::now() + Duration::from_secs(1); + cache.refresh("id", 1, &req, &http, deadline).await.unwrap(); + cache.refresh("id", 1, &req, &http, deadline).await.unwrap(); + assert_eq!(http.0.load(Ordering::SeqCst), 2); + assert!( + cache + .refresh("id", 2, &req, &Failed, deadline) + .await + .is_err() + ); + cache.refresh("id", 2, &req, &http, deadline).await.unwrap(); + assert_eq!(http.0.load(Ordering::SeqCst), 3); +} + +#[test] +fn validation() { + for data in [ + br#"{"error":"invalid_grant","secret":"DO NOT ECHO"}"#.as_slice(), + br#"{"access_token":"","token_type":"Bearer","expires_in":3600}"#, + br#"{"access_token":"x","token_type":"MAC","expires_in":3600}"#, + br#"{"access_token":"x","token_type":"Bearer","expires_in":0}"#, + br#"{"access_token":"x\r\ny","token_type":"Bearer","expires_in":3600}"#, + ] { + let e = decode(data).err().unwrap(); + assert!(!e.to_string().contains("DO NOT ECHO")); + } + assert!( + decode(br#"{"access_token":"x","token_type":"Bearer","expires_in":18446744073709551615}"#) + .is_ok() + ); +} diff --git a/host/tests/cred_regressions.rs b/host/tests/cred_regressions.rs new file mode 100644 index 0000000..adc8546 --- /dev/null +++ b/host/tests/cred_regressions.rs @@ -0,0 +1,319 @@ +//! Retained v2 item, grant, and transaction regressions over native SQLite. +use sausage::cred::{ + edits::{Delete, Edit}, + grants::Grant, + items::*, + requests::{GrantRequest, Overrides}, + script::{Limits, Script}, + *, +}; +struct Fake; +impl Script for Fake { + fn evaluate( + &self, + _: &str, + _: &str, + _: Option<&str>, + _: Limits, + ) -> std::result::Result { + Ok("true".into()) + } +} +fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { + let dir = tempfile::tempdir().unwrap(); + let cred = Cred::open(dir.path()).unwrap(); + let caller = Caller { + pid: 3, + program: "/bin/tool.wasm".into(), + conversations: vec!["root".into()], + }; + let item = cred + .create_item( + Item { + id: String::new(), + name: "item".into(), + version: 0, + free_use: false, + rules: vec![Rule { + scheme: "https".into(), + hosts: vec!["api.test".into()], + path_prefixes: vec!["/".into()], + methods: None, + }], + validate_js: None, + transform_js: String::new(), + fields: vec![ + Field { + field_id: String::new(), + name: "first".into(), + public: false, + value: None, + needs_value: true, + field_type: types::FieldType::Text, + key_type: None, + }, + Field { + field_id: String::new(), + name: "second".into(), + public: false, + value: None, + needs_value: true, + field_type: types::FieldType::Text, + key_type: None, + }, + ], + }, + &caller, + "pk", + ) + .unwrap(); + (dir, cred, caller, item) +} +fn parts() -> http::request::Parts { + http::Request::get("https://api.test/") + .body(()) + .unwrap() + .into_parts() + .0 +} +fn grant(item: &Item) -> Grant { + Grant { + id: String::new(), + items: vec![item.id.clone()], + subject: types::Subject::Conversation("root".into()), + exp: None, + max_uses: Some(1), + uses: 0, + revoked: false, + validator: None, + note: None, + } +} +#[test] +fn missing_values_and_wrong_master_spend_nothing() { + let (dir, cred, caller, item) = fixture(); + let grant = cred + .grant_create(grant(&item), &caller, Some("pk")) + .unwrap(); + assert_eq!( + cred.inject(parts(), &caller, &Fake).err().unwrap().0, + "needs_value" + ); + assert_eq!(cred.grant_status(&grant.id).unwrap().uses, 0); + for field in &item.fields { + cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk") + .unwrap(); + } + drop(cred); + std::fs::write(dir.path().join("cred.key"), [7u8; 32]).unwrap(); + let cred = Cred::open(dir.path()).unwrap(); + assert_eq!( + cred.inject(parts(), &caller, &Fake).err().unwrap().0, + "seal" + ); + assert_eq!(cred.grant_status(&grant.id).unwrap().uses, 0); +} +#[test] +fn field_name_swaps_preserve_ciphertexts_and_failed_edits_roll_back() { + let (dir, cred, caller, item) = fixture(); + for field in &item.fields { + cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk") + .unwrap(); + } + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + let cipher = || { + let mut q = db + .prepare("SELECT ciphertext FROM fields ORDER BY id") + .unwrap(); + q.query_map([], |r| r.get::<_, Vec>(0)) + .unwrap() + .collect::>>() + .unwrap() + }; + let before = cipher(); + let old = cred.item_get(&item.id).unwrap(); + let mut config = old.clone(); + config.fields[0].name = "second".into(); + config.fields[1].name = "first".into(); + let id = cred + .file_edit_request( + &caller, + "swap", + Edit { + item: item.id.clone(), + base_version: old.version, + config, + }, + ) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert!(cipher() == before); + let current = cred.item_get(&item.id).unwrap(); + let mut config = current.clone(); + config.fields.clear(); + let id = cred + .file_edit_request( + &caller, + "remove", + Edit { + item: item.id.clone(), + base_version: current.version, + config, + }, + ) + .unwrap(); + let history = cred.item_versions(&item.id).unwrap().len(); + let audit = cred.audit_list().unwrap().len(); + db.execute_batch("CREATE TRIGGER fail_decision BEFORE UPDATE ON requests BEGIN SELECT RAISE(ABORT,'forced'); END;").unwrap(); + assert!( + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .is_err() + ); + assert!(cipher() == before); + assert_eq!(cred.item_get(&item.id).unwrap().version, current.version); + assert_eq!(cred.item_versions(&item.id).unwrap().len(), history); + assert_eq!(cred.audit_list().unwrap().len(), audit); +} +#[test] +fn deleted_item_revert_preserves_identity_but_never_restores_secrets() { + let (_dir, cred, caller, item) = fixture(); + for field in &item.fields { + cred.set_field(&item.id, &field.field_id, "opaque", &caller, "pk") + .unwrap(); + } + let current = cred.item_get(&item.id).unwrap(); + let id = cred + .file_delete_request( + &caller, + "delete", + Delete { + item: item.id.clone(), + base_version: current.version, + }, + ) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let id = cred + .file_revert_request( + &caller, + "restore", + &item.id, + current.version + 1, + current.version, + ) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let restored = cred.item_get(&item.id).unwrap(); + assert_eq!(restored.id, item.id); + assert_eq!(restored.version, current.version + 2); + assert!( + restored + .fields + .iter() + .all(|f| f.needs_value && f.value.is_none()) + ); + assert_eq!(restored.fields[0].field_id, item.fields[0].field_id); +} +#[test] +fn secret_entry_keeps_oauth_cardinality_and_rolls_back_ciphertext() { + let (dir, cred, caller, item) = fixture(); + let input = FieldInput { + value: "opaque", + field_type: Some(types::FieldType::Oauth2RefreshToken), + key_type: None, + }; + cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, "pk") + .unwrap(); + cred.set_field(&item.id, &item.fields[1].field_id, "opaque", &caller, "pk") + .unwrap(); + let current = cred.item_get(&item.id).unwrap(); + let history = cred.item_versions(&item.id).unwrap().len(); + let audit = cred.audit_list().unwrap().len(); + let db = rusqlite::Connection::open(dir.path().join("cred.sqlite")).unwrap(); + let cipher = || { + db.query_row( + "SELECT ciphertext FROM fields WHERE item=?1 AND id=?2", + rusqlite::params![item.id, item.fields[1].field_id], + |r| r.get::<_, Vec>(0), + ) + .unwrap() + }; + let before = cipher(); + assert_eq!( + cred.set_field_input(&item.id, &item.fields[1].field_id, &input, &caller, "pk") + .err() + .unwrap() + .0, + "invalid_params" + ); + assert!(cipher() == before); + assert_eq!(cred.item_get(&item.id).unwrap().version, current.version); + assert_eq!(cred.item_versions(&item.id).unwrap().len(), history); + assert_eq!(cred.audit_list().unwrap().len(), audit); +} +#[test] +fn typed_secret_entry_and_bundle_normalization() { + let (_dir, cred, caller, item) = fixture(); + let mut metadata = item.clone(); + metadata.fields[0].field_type = types::FieldType::SshPrivateKey; + metadata.fields[0].key_type = Some("es256".into()); + let id = cred + .file_edit_request( + &caller, + "metadata", + Edit { + item: item.id.clone(), + base_version: item.version, + config: metadata, + }, + ) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let input = FieldInput { + value: "two\nlines", + field_type: Some(types::FieldType::Note), + key_type: None, + }; + cred.set_field_input(&item.id, &item.fields[0].field_id, &input, &caller, "pk") + .unwrap(); + let current = cred.item_get(&item.id).unwrap(); + assert_eq!(current.fields[0].field_type, types::FieldType::Note); + assert!(current.fields[0].key_type.is_none()); + assert!(current.fields[0].value.is_none()); + assert_eq!( + cred.item_versions(&item.id).unwrap()[0].config.fields[0].field_type, + types::FieldType::Text + ); + let p = GrantRequest { + items: vec![item.name.clone(), item.id.clone()], + duration_secs: None, + max_uses: Some(1), + validator: None, + }; + let id = cred.file_request(&caller, "bundle", p).unwrap(); + let p = GrantRequest { + items: vec![item.id], + duration_secs: None, + max_uses: Some(1), + validator: None, + }; + assert_eq!(cred.file_request(&caller, "bundle", p).unwrap(), id); + cred.approve( + &id, + "yes", + Overrides { + unlimited: true, + ..Overrides::default() + }, + None, + ) + .unwrap(); + let g = cred.grant_list().unwrap().remove(0); + assert_eq!(g.items.len(), 1); + assert!(g.max_uses.is_none()); + assert!(g.exp.is_some()); +} diff --git a/host/tests/cred_replay.rs b/host/tests/cred_replay.rs new file mode 100644 index 0000000..7cd9d75 --- /dev/null +++ b/host/tests/cred_replay.rs @@ -0,0 +1,346 @@ +//! v2 keyed replay compares intent before current item validation or edit planning. +use sausage::cred::{ + edits::*, + items::*, + requests::{GrantRequest, Overrides}, + types::FieldType, + *, +}; +fn fixture() -> (tempfile::TempDir, Cred, Caller, Item) { + let dir = tempfile::tempdir().unwrap(); + let cred = Cred::open(dir.path()).unwrap(); + let caller = Caller { + pid: 1, + program: "/bin/tool".into(), + conversations: vec!["root".into()], + }; + let item = cred + .create_item( + Item { + id: String::new(), + name: "item".into(), + version: 0, + free_use: false, + rules: vec![], + validate_js: None, + transform_js: String::new(), + fields: vec![Field { + field_id: String::new(), + name: "token".into(), + public: false, + value: None, + needs_value: true, + field_type: FieldType::Text, + key_type: None, + }], + }, + &caller, + "pk", + ) + .unwrap(); + (dir, cred, caller, item) +} +#[test] +fn approved_operations_and_deletes_replay() { + let (_dir, cred, caller, item) = fixture(); + let request = OperationRequest { + item: Some(item.id.clone()), + base_version: Some(item.version), + change: vec![Operation::SetName { + name: "renamed".into(), + }], + }; + let id = cred + .file_operation_request(&caller, "edit", request.clone()) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.file_operation_request(&caller, "edit", request) + .unwrap(), + id + ); + let current = cred.item_get(&item.id).unwrap(); + let delete = Delete { + item: item.id, + base_version: current.version, + }; + let id = cred + .file_delete_request(&caller, "delete", delete.clone()) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.file_delete_request(&caller, "delete", delete).unwrap(), + id + ); +} +#[test] +fn approved_replacements_and_creations_replay() { + let (_dir, cred, caller, item) = fixture(); + let mut config = item.clone(); + config.name = "renamed".into(); + let edit = Edit { + item: item.id, + base_version: item.version, + config: config.clone(), + }; + let id = cred + .file_edit_request(&caller, "replace", edit.clone()) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.file_edit_request(&caller, "replace", edit).unwrap(), + id + ); + config.name = "new".into(); + let request = OperationRequest { + item: None, + base_version: None, + change: vec![Operation::CreateItem { config }], + }; + let id = cred + .file_operation_request(&caller, "create", request.clone()) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.file_operation_request(&caller, "create", request) + .unwrap(), + id + ); + assert_eq!(cred.item_list().unwrap().len(), 2); +} +#[test] +fn grant_defaults_normalize_and_pending_requests_replay_after_deletion() { + let (_dir, cred, caller, item) = fixture(); + let payload = GrantRequest { + items: vec![item.id.clone()], + duration_secs: None, + max_uses: None, + validator: Some(String::new()), + }; + let id = cred.file_request(&caller, "grant", payload).unwrap(); + let payload = GrantRequest { + items: vec![item.id.clone()], + duration_secs: Some(3600), + max_uses: None, + validator: None, + }; + assert_eq!( + cred.file_request(&caller, "grant", payload.clone()) + .unwrap(), + id + ); + let delete = cred + .file_delete_request( + &caller, + "delete", + Delete { + item: item.id, + base_version: item.version, + }, + ) + .unwrap(); + cred.approve(&delete, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!(cred.file_request(&caller, "grant", payload).unwrap(), id); + assert!( + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .is_err() + ); + assert_eq!( + cred.request_list() + .unwrap() + .iter() + .find(|r| r.id == id) + .unwrap() + .state, + "pending" + ); +} +#[test] +fn values_are_checked_at_each_public_operation() { + let (_dir, cred, caller, item) = fixture(); + let mut config = item.clone(); + config.fields.push(Field { + field_id: String::new(), + name: "public".into(), + public: true, + value: Some("old".into()), + needs_value: false, + field_type: FieldType::Text, + key_type: None, + }); + let id = cred + .file_edit_request( + &caller, + "setup", + Edit { + item: item.id.clone(), + base_version: item.version, + config, + }, + ) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + let current = cred.item_get(&item.id).unwrap(); + let field = current + .fields + .iter() + .find(|f| f.name == "public") + .unwrap() + .field_id + .clone(); + let mut request = OperationRequest { + item: Some(item.id.clone()), + base_version: Some(current.version), + change: vec![ + Operation::SetFieldValue { + field: field.clone(), + value: "bad\nvalue".into(), + }, + Operation::SetFieldType { + field, + field_type: FieldType::Note, + key_type: None, + }, + ], + }; + assert_eq!( + cred.file_operation_request(&caller, "invalid", request.clone()) + .err() + .unwrap() + .0, + "invalid_value" + ); + assert_eq!(cred.item_get(&item.id).unwrap().version, current.version); + request.change.reverse(); + let id = cred + .file_operation_request(&caller, "ordered", request) + .unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.item_get(&item.id).unwrap().version, + current.version + 1 + ); +} +#[test] +fn secret_retyping_checks_each_operation_only_on_approval() { + let (_dir, cred, caller, item) = fixture(); + let field = &item.fields[0].field_id; + cred.set_field_input( + &item.id, + field, + &FieldInput { + value: "VALUE_SENTINEL\nTAIL_SENTINEL", + field_type: Some(FieldType::Note), + key_type: None, + }, + &caller, + "pk", + ) + .unwrap(); + let current = cred.item_get(&item.id).unwrap(); + let request = OperationRequest { + item: Some(item.id.clone()), + base_version: Some(current.version), + change: vec![ + Operation::SetFieldType { + field: field.clone(), + field_type: FieldType::Text, + key_type: None, + }, + Operation::SetFieldType { + field: field.clone(), + field_type: FieldType::Note, + key_type: None, + }, + ], + }; + let id = cred + .file_operation_request(&caller, "types", request) + .unwrap(); + assert!( + !cred.request_list().unwrap()[0] + .payload + .to_string() + .contains("VALUE_SENTINEL") + ); + assert_eq!( + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .err() + .unwrap() + .0, + "invalid_value" + ); + assert_eq!(cred.item_get(&item.id).unwrap().version, current.version); + assert_eq!(cred.request_list().unwrap()[0].state, "pending"); + let history = cred.item_versions(&item.id).unwrap().len(); + cred.set_field(&item.id, field, "single-line", &caller, "pk") + .unwrap(); + let next = cred.item_get(&item.id).unwrap(); + assert_eq!(next.version, current.version + 1); + assert_eq!(cred.item_versions(&item.id).unwrap().len(), history + 1); + cred.resolve_stale(&id, next.version).unwrap(); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); +} +#[test] +fn stale_rebases_keep_original_keyed_intent() { + let (_dir, cred, caller, item) = fixture(); + let request = OperationRequest { + item: Some(item.id.clone()), + base_version: Some(item.version), + change: vec![Operation::SetName { + name: "renamed".into(), + }], + }; + let id = cred + .file_operation_request(&caller, "rebase", request.clone()) + .unwrap(); + cred.set_field(&item.id, &item.fields[0].field_id, "opaque", &caller, "pk") + .unwrap(); + let current = cred.item_get(&item.id).unwrap(); + cred.resolve_stale(&id, current.version).unwrap(); + assert_eq!( + cred.file_operation_request(&caller, "rebase", request.clone()) + .unwrap(), + id + ); + cred.approve(&id, "yes", Overrides::default(), Some("pk")) + .unwrap(); + assert_eq!( + cred.file_operation_request(&caller, "rebase", request) + .unwrap(), + id + ); + assert!(!cred.item_get(&item.id).unwrap().fields[0].needs_value); +} +#[test] +fn operation_order_is_part_of_key_identity() { + let (_dir, cred, caller, item) = fixture(); + let mut request = OperationRequest { + item: Some(item.id), + base_version: Some(item.version), + change: vec![ + Operation::SetName { + name: "renamed".into(), + }, + Operation::SetFreeUse { enabled: true }, + ], + }; + cred.file_operation_request(&caller, "ordered", request.clone()) + .unwrap(); + request.change.reverse(); + assert_eq!( + cred.file_operation_request(&caller, "ordered", request) + .err() + .unwrap() + .0, + "key_conflict" + ); +} diff --git a/host/tests/cred_sign.rs b/host/tests/cred_sign.rs new file mode 100644 index 0000000..debac77 --- /dev/null +++ b/host/tests/cred_sign.rs @@ -0,0 +1,283 @@ +//! RFC vectors and public codecs for pure signing. +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use sausage::cred::sign::*; +fn raw(key: &[u8], alg: &str, payload: &[u8]) -> Vec { + sign(&STANDARD.encode(key), None, alg, payload, "raw", None, None) + .unwrap() + .bytes + .unwrap() +} +#[test] +fn ed25519_readers_and_wire() { + use ed25519_dalek::pkcs8::EncodePrivateKey; + let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]); + let pem = key.to_pkcs8_pem(Default::default()).unwrap(); + let jwk = serde_json::json!({"kty":"OKP","crv":"Ed25519","d":URL_SAFE_NO_PAD.encode(key.to_bytes()),"x":URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes())}).to_string(); + let ssh = ssh_key::PrivateKey::new( + ssh_key::private::Ed25519Keypair::from(&key).into(), + "synthetic", + ) + .unwrap() + .to_openssh(Default::default()) + .unwrap(); + let expected = raw(&key.to_bytes(), "ed25519", b"message"); + for value in [pem.as_str(), jwk.as_str(), ssh.as_str()] { + let sig = sign(value, None, "ed25519", b"message", "ssh", None, None).unwrap(); + let wire = STANDARD.decode(sig.signature).unwrap(); + assert_eq!(&wire[..19], b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x40"); + assert_eq!(&wire[19..], expected); + for output in ["pem", "jwk", "ssh", "hex", "base64"] { + assert!(public_key(value, None, "ed25519", output).is_ok()); + } + } + assert!(public_key(&STANDARD.encode([7; 32]), None, "hmac-sha256", "hex").is_err()); +} + +#[test] +fn rfc8032_and_4231() { + let seed = + hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60").unwrap(); + assert_eq!( + hex::encode(raw(&seed, "ed25519", b"")), + concat!( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155", + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + ) + ); + for (alg, expected) in [ + ( + "hmac-sha256", + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7", + ), + ( + "hmac-sha384", + "afd03944d84895626b0825f4ab46907f15f9dadbe4101ec682aa034c7cebc59cfaea9ea9076ede7f4af152e8b2fa9cb6", + ), + ( + "hmac-sha512", + "87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cde daa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854", + ), + ] { + assert_eq!( + hex::encode(raw(&[0x0b; 20], alg, b"Hi There")), + expected.replace(' ', "") + ); + } +} +#[test] +fn ec_readers() { + use p256::pkcs8::EncodePrivateKey; + let secret = p256::SecretKey::from_slice(&[7; 32]).unwrap(); + let public = secret.public_key(); + use p256::elliptic_curve::sec1::ToEncodedPoint; + let point = public.to_encoded_point(false); + let jwk = serde_json::json!({"kty":"EC","crv":"P-256","d":URL_SAFE_NO_PAD.encode(secret.to_bytes()),"x":URL_SAFE_NO_PAD.encode(point.x().unwrap()),"y":URL_SAFE_NO_PAD.encode(point.y().unwrap())}).to_string(); + let pkcs8 = secret.to_pkcs8_pem(Default::default()).unwrap(); + let sec1 = secret.to_sec1_pem(Default::default()).unwrap(); + let expected = raw(&[7; 32], "es256", b"message"); + let ssh = ssh_key::PrivateKey::from(ssh_key::private::EcdsaKeypair::NistP256 { + public: point, + private: ssh_key::private::EcdsaPrivateKey::from(secret.clone()), + }) + .to_openssh(Default::default()) + .unwrap(); + for value in [jwk.as_str(), pkcs8.as_str(), sec1.as_str(), ssh.as_str()] { + assert_eq!( + sign(value, None, "es256", b"message", "raw", None, None) + .unwrap() + .bytes + .unwrap(), + expected + ); + let public = ssh_key::PublicKey::from_bytes( + &STANDARD + .decode(public_key(value, None, "es256", "ssh").unwrap()) + .unwrap(), + ) + .unwrap(); + let wire = STANDARD + .decode( + sign(value, None, "es256", b"message", "ssh", None, None) + .unwrap() + .signature, + ) + .unwrap(); + let signature = ssh_key::Signature::try_from(wire.as_slice()).unwrap(); + ed25519_dalek::Verifier::verify(&public, b"message", &signature).unwrap(); + } + let mut bad: serde_json::Value = serde_json::from_str(&jwk).unwrap(); + bad["x"] = serde_json::json!(URL_SAFE_NO_PAD.encode([0; 32])); + assert!( + sign( + &bad.to_string(), + None, + "es256", + b"message", + "raw", + None, + None + ) + .is_err() + ); +} + +#[test] +fn rfc6979_and_formats() { + let key = + hex::decode("c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721").unwrap(); + let expected = concat!( + "efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716", + "f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8" + ); + assert_eq!(hex::encode(raw(&key, "es256", b"sample")), expected); + let key = STANDARD.encode(key); + for output in ["hex", "base64", "jws", "ssh"] { + let result = sign(&key, None, "es256", b"sample", output, None, None).unwrap(); + assert!(!result.signature.is_empty()); + } + for output in ["hex", "base64", "pem", "jwk", "ssh"] { + assert!(!public_key(&key, None, "es256", output).unwrap().is_empty()); + } + let pem = generate_es256().unwrap(); + assert!(public_key(&pem, None, "es256", "jwk").is_ok()); + for bad in [vec![], vec![1; 31], vec![0; 32], vec![255; 32]] { + assert!(sign(&STANDARD.encode(bad), None, "es256", b"", "raw", None, None).is_err()); + } +} +#[test] +fn rsa_readers_and_ssh_wire() { + use rsa::{ + pkcs1::EncodeRsaPrivateKey, + pkcs8::EncodePrivateKey, + traits::{PrivateKeyParts, PublicKeyParts}, + }; + use sha2::{Digest, Sha256, Sha512}; + let key = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 1024).unwrap(); + let pem = key.to_pkcs8_pem(Default::default()).unwrap(); + let pkcs1 = key.to_pkcs1_pem(Default::default()).unwrap(); + let ssh = ssh_key::PrivateKey::new( + ssh_key::private::RsaKeypair::try_from(&key).unwrap().into(), + "synthetic", + ) + .unwrap(); + let openssh = ssh.to_openssh(Default::default()).unwrap(); + let encrypted = ssh + .encrypt(&mut rsa::rand_core::OsRng, "synthetic") + .unwrap() + .to_openssh(Default::default()) + .unwrap(); + let jwk = serde_json::json!({"kty":"RSA", "n":URL_SAFE_NO_PAD.encode(key.n().to_bytes_be()), "e":URL_SAFE_NO_PAD.encode(key.e().to_bytes_be()), "d":URL_SAFE_NO_PAD.encode(key.d().to_bytes_be()), "p":URL_SAFE_NO_PAD.encode(key.primes()[0].to_bytes_be()), "q":URL_SAFE_NO_PAD.encode(key.primes()[1].to_bytes_be())}).to_string(); + for (value, pass) in [ + (pem.as_str(), None), + (pkcs1.as_str(), None), + (openssh.as_str(), None), + (encrypted.as_str(), Some("synthetic")), + (jwk.as_str(), None), + ] { + for (alg, hash) in [ + ("rs256", "sha256"), + ("rsa-sha2-256", "sha256"), + ("rsa-sha2-512", "sha512"), + ] { + let result = sign( + value, + pass, + alg, + b"message", + "raw", + Some("pkcs1v15"), + Some(hash), + ) + .unwrap(); + let sig = result.bytes.unwrap(); + if hash == "sha512" { + key.to_public_key() + .verify( + rsa::Pkcs1v15Sign::new::(), + &Sha512::digest(b"message"), + &sig, + ) + .unwrap(); + } else { + key.to_public_key() + .verify( + rsa::Pkcs1v15Sign::new::(), + &Sha256::digest(b"message"), + &sig, + ) + .unwrap(); + } + let wire = STANDARD + .decode( + sign( + value, + pass, + alg, + b"message", + "ssh", + Some("pkcs1v15"), + Some(hash), + ) + .unwrap() + .signature, + ) + .unwrap(); + let len = u32::from_be_bytes(wire[..4].try_into().unwrap()) as usize; + assert_eq!( + &wire[4..4 + len], + if hash == "sha512" { + b"rsa-sha2-512" + } else { + b"rsa-sha2-256" + } + ); + assert_eq!(&wire[8 + len..], sig); + } + for output in ["pem", "jwk", "ssh", "base64", "hex"] { + assert!(public_key(value, pass, "rs256", output).is_ok()); + } + } + assert!(sign(&pem, None, "rs256", b"", "raw", None, None).is_err()); +} + +#[test] +fn malformed_and_mismatched_keys() { + for value in [ + "not a key", + "{broken", + "{\"kty\":\"unknown\"}", + "-----BEGIN UNKNOWN PRIVATE KEY-----\nAAAA\n-----END UNKNOWN PRIVATE KEY-----", + "-----BEGIN OPENSSH PRIVATE KEY-----\ninvalid", + "AAAA", + ] { + assert!(sign(value, None, "ed25519", b"", "raw", None, None).is_err()); + } + let bad=serde_json::json!({"kty":"OKP","crv":"Ed25519","d":URL_SAFE_NO_PAD.encode([1;32]),"x":URL_SAFE_NO_PAD.encode([2;32])}).to_string(); + assert!(sign(&bad, None, "ed25519", b"", "raw", None, None).is_err()); + let oct = serde_json::json!({"kty":"oct","k":URL_SAFE_NO_PAD.encode([11;20])}).to_string(); + assert!(sign(&oct, None, "hmac-sha384", b"Hi There", "raw", None, None).is_ok()); + assert!(sign(&oct, None, "ed25519", b"", "raw", None, None).is_err()); + assert!(sign(&oct, None, "hmac-sha384", b"", "ssh", None, None).is_err()); + assert!(sign(&oct, None, "hmac-sha384", b"", "unknown", None, None).is_err()); +} + +#[test] +fn rfc8292() { + use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; + let public=URL_SAFE_NO_PAD.decode("BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs").unwrap(); + let signature=URL_SAFE_NO_PAD.decode("i3CYb7t4xfxCDquptFOepC9GAu_HLGkMlMuCGSK2rpiUfnK9ojFwDXb1JrErtmysazNjjvW2L9OkSSHzvoD1oA").unwrap(); + let input = concat!( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.", + "eyJhdWQiOiJodHRwczovL3B1c2guZXhhbXBsZS5uZXQiLCJleHAiOjE0NTM1MjM3NjgsInN1YiI6Im1haWx0bzpwdXNoQGV4YW1wbGUuY29tIn0" + ); + VerifyingKey::from_sec1_bytes(&public) + .unwrap() + .verify( + input.as_bytes(), + &Signature::from_slice(&signature).unwrap(), + ) + .unwrap(); +} diff --git a/host/tests/cred_summary.rs b/host/tests/cred_summary.rs new file mode 100644 index 0000000..fef420b --- /dev/null +++ b/host/tests/cred_summary.rs @@ -0,0 +1,84 @@ +//! Public approval text and template presets from v2, without vault migrations. +use sausage::cred::{ + edits::Operation, + items::{Field, FieldInput}, + summary::*, + types::FieldType, +}; +#[test] +fn durations_use_plain_language() { + for (seconds, expected) in [ + (0, "0 seconds"), + (1, "1 second"), + (60, "1 minute"), + (3600, "1 hour"), + (93604, "1 day 2 hours 4 seconds"), + ] { + assert_eq!(human_duration(seconds), expected); + } +} +#[test] +fn summaries_never_include_field_values_or_script_sources() { + let value = "SECRET_SUMMARY_SENTINEL"; + for operation in [ + Operation::SetFieldValue { + field: "id".into(), + value: value.into(), + }, + Operation::SetTransformJs { + value: value.into(), + }, + Operation::AddField { + field: Field { + field_id: String::new(), + name: "token".into(), + public: false, + value: Some(value.into()), + needs_value: true, + field_type: FieldType::Text, + key_type: None, + }, + }, + ] { + assert!(!operation_summary(&operation).contains(value)); + } + let input = FieldInput { + value, + field_type: None, + key_type: None, + }; + assert!(!format!("{input:?}").contains(value)); +} +#[test] +fn summary_names_are_inert_and_stay_on_one_line() { + let text = operation_summary(&Operation::SetName { + name: "&*\nnext".into(), + }); + assert!(!text.contains('<')); + assert!(!text.contains('\n')); + assert!(text.contains("<x>&\\* next")); +} +#[test] +fn presets_preserve_headers_and_utf8_basic_encoding() { + assert!( + script("bearer") + .unwrap() + .contains("'Bearer ' + get('token')") + ); + assert!( + script("basic") + .unwrap() + .contains("unescape(encodeURIComponent") + ); + assert!( + script("header:x-api-key") + .unwrap() + .contains("const name = \"x-api-key\"") + ); + assert!( + script("query:access_token") + .unwrap() + .contains("url.query.filter") + ); + assert_eq!(script("unknown").unwrap_err().0, "invalid_preset"); +} diff --git a/host/tests/cred_v8.rs b/host/tests/cred_v8.rs new file mode 100644 index 0000000..6eba8fa --- /dev/null +++ b/host/tests/cred_v8.rs @@ -0,0 +1,222 @@ +//! Behavioral contract for the credential-only isolate. +use sausage::cred::{ + script::{ErrorKind, Limits, Script}, + v8::V8, +}; +use std::time::{Duration, Instant}; +fn run(s: &str) -> Result { + V8.evaluate(s, "null", None, Limits::default()) +} +#[test] +fn modules_promises_and_json() { + assert_eq!( + V8.evaluate( + "JSON.parse=()=>false; export default x=>x", + "42", + None, + Limits::default() + ) + .unwrap(), + "42" + ); + let error = run("export default ()=>{throw Error('DO NOT ECHO')}").unwrap_err(); + assert!(!error.message.contains("DO NOT ECHO")); + assert_eq!( + V8.evaluate( + "export default x=>({value:x}); export const other=x=>x+1", + "42", + None, + Limits::default() + ) + .unwrap(), + "{\"value\":42}" + ); + assert_eq!( + V8.evaluate( + "export const other=x=>x+1", + "42", + Some("other"), + Limits::default() + ) + .unwrap(), + "43" + ); + assert_eq!( + run("await Promise.resolve(); export default async x=>{await Promise.resolve();return 42}") + .unwrap(), + "42" + ); + for (s, k) in [ + ("export default (", ErrorKind::Syntax), + ("export default ()=>{throw Error('oops')}", ErrorKind::Throw), + ("export default ()=>Promise.reject('no')", ErrorKind::Throw), + ("export default ()=>undefined", ErrorKind::NonJson), + ("export default ()=>Symbol()", ErrorKind::NonJson), + ("export default ()=>()=>1", ErrorKind::NonJson), + ( + "export default ()=>{let a={};a.a=a;return a}", + ErrorKind::NonJson, + ), + ("export const x=1", ErrorKind::Engine), + ("export default 1", ErrorKind::Engine), + ("export default ()=>new Promise(()=>{})", ErrorKind::Engine), + ] { + assert_eq!(run(s).unwrap_err().kind, k, "{s}"); + } + assert_eq!( + V8.evaluate("export default x=>x", "invalid", None, Limits::default()) + .unwrap_err() + .kind, + ErrorKind::Syntax + ); +} +#[test] +fn sandbox_and_isolation() { + for name in [ + "Date", + "Math.random", + "fetch", + "setTimeout", + "setInterval", + "WebSocket", + "XMLHttpRequest", + "std", + "os", + "fs", + "Deno", + "__bootstrap", + "console", + "performance", + "require", + "process", + "TextEncoder", + "Buffer", + "Blob", + "File", + "crypto", + ] { + assert_eq!( + run(&format!("export default ()=>typeof {name}")).unwrap(), + "\"undefined\"", + "{name}" + ); + } + assert!(run("import x from 'fs'; export default ()=>x").is_err()); + assert!(run("import {core} from 'ext:core/mod.js'; export default ()=>typeof core").is_err()); + assert_eq!( + run("export default ()=>import('fs')").unwrap_err().kind, + ErrorKind::Throw + ); + run("export default ()=>{globalThis.leak=1;btoa.leak=1;return true}").unwrap(); + assert_eq!( + run("export default ()=>[typeof leak,typeof btoa.leak]").unwrap(), + "[\"undefined\",\"undefined\"]" + ); + for (expr, expected) in [ + ("btoa('user:pass')", "\"dXNlcjpwYXNz\""), + ("atob('dXNlcjpwYXNz')", "\"user:pass\""), + ("btoa('')", "\"\""), + ("atob('AP+A') === '\\x00\\xff\\x80'", "true"), + ] { + assert_eq!( + run(&format!("export default ()=>{expr}")).unwrap(), + expected + ); + } + for expr in ["atob('!')", "btoa('雪')"] { + assert_eq!( + run(&format!("export default ()=>{expr}")).unwrap_err().kind, + ErrorKind::Throw + ); + } +} +#[test] +fn independent_deadlines() { + // Exclude the process-wide platform initialization from the overlap check. + assert_eq!(run("export default ()=>true").unwrap(), "true"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let other = barrier.clone(); + let runaway = std::thread::spawn(move || { + other.wait(); + assert_eq!( + V8.evaluate( + "export default ()=>{while(true){}}", + "null", + None, + Limits { + max_time_ms: Some(1000), + ..Limits::default() + } + ) + .unwrap_err() + .kind, + ErrorKind::Limit + ); + }); + barrier.wait(); + // The quick call keeps its default 100 ms execution deadline. + assert_eq!(run("export default ()=>true").unwrap(), "true"); + assert!( + !runaway.is_finished(), + "the quick call must finish before the runaway call" + ); + runaway.join().unwrap(); +} + +#[test] +fn clamped_limits() { + for limits in [ + Limits { + max_time_ms: Some(0), + max_memory_bytes: Some(0), + }, + Limits { + max_time_ms: Some(u64::MAX), + max_memory_bytes: Some(usize::MAX), + }, + ] { + match V8.evaluate("export default ()=>true", "null", None, limits) { + Ok(value) => assert_eq!(value, "true"), + Err(error) => assert_eq!(error.kind, ErrorKind::Limit), + } + } + assert_eq!( + V8.evaluate( + "export default ()=>{let a=[];while(1)a.push('abcdefghijklmnopqrstuvwxyz'+a.length)}", + "null", + None, + Limits { + max_time_ms: Some(1000), + max_memory_bytes: Some(1024 * 1024) + } + ) + .unwrap_err() + .kind, + ErrorKind::Limit + ); +} + +#[test] +fn limits_and_parallel_calls() { + let mut workers = Vec::new(); + for s in [ + "export default ()=>{while(true){}}", + "while(true){};export default x=>x", + "export default ()=>({toJSON(){while(true){}}})", + "export default ()=>{throw {get message(){while(true){}}}}", + "export default ()=>{function f(){return f()+1}return f()}", + "export default ()=>new Promise(()=>{function f(){Promise.resolve().then(f)}f()})", + "export default ()=>{let a=[];while(1)a.push('abcdefghijklmnopqrstuvwxyz'+a.length)}", + ] { + workers.push(std::thread::spawn(move || { + let start = Instant::now(); + assert_eq!(run(s).unwrap_err().kind, ErrorKind::Limit); + assert!(start.elapsed() < Duration::from_secs(2)); + })); + } + let normal = std::thread::spawn(|| assert_eq!(run("export default ()=>true").unwrap(), "true")); + for worker in workers { + worker.join().unwrap(); + } + normal.join().unwrap(); +} diff --git a/host/tests/cred_webauthn.rs b/host/tests/cred_webauthn.rs new file mode 100644 index 0000000..da078a0 --- /dev/null +++ b/host/tests/cred_webauthn.rs @@ -0,0 +1,124 @@ +//! Software-authenticator coverage for enrollment gates and bound assertions. +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use sausage::cred::webauthn::{Binding, Gate, Passkeys}; +use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; +use webauthn_rs::prelude::*; + +fn binding() -> Binding { + Binding::Decision { + request_id: "request-1".into(), + decision: "approve".into(), + overrides: serde_json::json!({}), + } +} + +#[test] +fn enrollment_and_assertions() -> anyhow::Result<()> { + let db = rusqlite::Connection::open_in_memory()?; + let origin = Url::parse("http://localhost:8000")?; + let mut host = Passkeys::new(&db, "localhost", origin.as_str())?; + let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + assert!(host.is_unlocked()); + let start = host.start_registration("first".into(), None)?; + let racing = host.start_registration("racing".into(), None)?; + let response = auth.do_registration(origin.clone(), start.options)?; + host.finish_registration(&start.id, response)?; + assert!(!host.is_unlocked()); + assert!(host.start_registration("second".into(), None).is_err()); + let response = auth.do_registration(origin.clone(), racing.options)?; + assert!(host.finish_registration(&racing.id, response).is_err()); + let gate = host.start_assertion(Binding::Enrollment { + label: "second".into(), + })?; + let response = auth.do_authentication(origin.clone(), gate.options)?; + let start = host.start_registration( + "second".into(), + Some(Gate { + id: gate.id, + response, + }), + )?; + let response = auth.do_registration(origin.clone(), start.options)?; + host.finish_registration(&start.id, response)?; + + let start = host.start_assertion(binding())?; + let response = auth.do_authentication(origin.clone(), start.options)?; + let replay = serde_json::to_value(&response)?; + assert_eq!(host.finish_assertion(&start.id, response)?.1, binding()); + assert!( + host.finish_assertion(&start.id, serde_json::from_value(replay)?) + .is_err() + ); + + let gate = host.start_assertion(binding())?; + let response = auth.do_authentication(origin.clone(), gate.options)?; + assert!( + host.start_registration( + "unauthorized".into(), + Some(Gate { + id: gate.id, + response + }) + ) + .is_err() + ); + + drop(host); + let mut host = Passkeys::new(&db, "localhost", origin.as_str())?; + let older = host.start_assertion(binding())?; + let newer = host.start_assertion(binding())?; + let old_response = auth.do_authentication(origin.clone(), older.options)?; + let new_response = auth.do_authentication(origin.clone(), newer.options)?; + host.finish_assertion(&newer.id, new_response)?; + assert!(host.finish_assertion(&older.id, old_response).is_err()); + + // Sign a different, valid random challenge with the same credential. + let a = host.start_assertion(binding())?; + let b = host.start_assertion(binding())?; + let response = auth.do_authentication(origin.clone(), b.options)?; + assert!(host.finish_assertion(&a.id, response).is_err()); + + let a = host.start_assertion(binding())?; + let response = auth.do_authentication(Url::parse("http://localhost:8001")?, a.options)?; + assert!(host.finish_assertion(&a.id, response).is_err()); + + let mut a = host.start_assertion(binding())?; + a.options.public_key.user_verification = + serde_json::from_value(serde_json::json!("discouraged"))?; + let response = auth.do_authentication(origin.clone(), a.options)?; + assert!(host.finish_assertion(&a.id, response).is_err()); + + for field in ["signature", "authenticatorData"] { + let a = host.start_assertion(binding())?; + let response = auth.do_authentication(origin.clone(), a.options)?; + let mut json = serde_json::to_value(response)?; + let mut bytes = URL_SAFE_NO_PAD.decode(json["response"][field].as_str().unwrap())?; + let index = if field == "signature" { + bytes.len() - 1 + } else { + 0 + }; + bytes[index] ^= 1; // Preserve signature encoding, or change rpIdHash. + json["response"][field] = URL_SAFE_NO_PAD.encode(bytes).into(); + assert!( + host.finish_assertion(&a.id, serde_json::from_value(json)?) + .is_err() + ); + } + let foreign_db = rusqlite::Connection::open_in_memory()?; + let mut foreign = Passkeys::new(&foreign_db, "localhost", origin.as_str())?; + let mut foreign_auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let registration = foreign.start_registration("foreign".into(), None)?; + let response = foreign_auth.do_registration(origin.clone(), registration.options)?; + let credential = foreign.finish_registration(®istration.id, response)?; + let mut a = host.start_assertion(binding())?; + a.options.public_key.allow_credentials[0].id = credential.cred_id().to_vec().into(); + let response = foreign_auth.do_authentication(origin.clone(), a.options)?; + assert!(host.finish_assertion(&a.id, response).is_err()); + + let a = host.start_assertion(binding())?; + let response = auth.do_authentication(origin, a.options)?; + db.execute("DELETE FROM passkey_credentials", [])?; + assert!(host.finish_assertion(&a.id, response).is_err()); + Ok(()) +} diff --git a/host/tests/durable.rs b/host/tests/durable.rs new file mode 100644 index 0000000..3f38086 --- /dev/null +++ b/host/tests/durable.rs @@ -0,0 +1,325 @@ +//! Credential decisions wake an idle faux-model conversation through durable tasks. +use sausage::{ + cred::{ + items::Item, + requests::{GrantRequest, Overrides}, + store::Cred, + types::Caller, + }, + harness::{run_module, run_module_with_credentials}, + world::World, +}; +use std::sync::Arc; + +#[tokio::test(flavor = "current_thread")] +async fn restart_child() -> anyhow::Result<()> { + let Ok(dir) = std::env::var("DURABLE_RESTART_DIR") else { + return Ok(()); + }; + let dir = std::path::Path::new(&dir); + let source = format!( + "globalThis.expectedState = 'approved';\n{}", + include_str!("../js/durable-test-bundle.js") + ); + run_module( + World::open(dir)?, + sausage::process::exec(World::open(dir)?)?, + &source, + ) + .await +} + +#[test] +fn sigkill_preserves_request_wait() -> anyhow::Result<()> { + use std::{ + process::Command, + time::{Duration, Instant}, + }; + let dir = std::env::temp_dir().join(format!("sausage-durable-restart-{}", std::process::id())); + let world = World::open(&dir)?; + let cred = Cred::open(&world.host_dir)?; + let admin = Caller { + pid: 0, + program: "test".into(), + conversations: vec![], + }; + let item = cred.create_item( + Item { + id: String::new(), + name: "github".into(), + version: 0, + free_use: false, + rules: vec![], + validate_js: None, + transform_js: "export default (ctx) => ctx.request".into(), + fields: vec![], + }, + &admin, + "test", + )?; + let mut child = Command::new(std::env::current_exe()?) + .args(["--exact", "restart_child", "--nocapture"]) + .env("DURABLE_RESTART_DIR", &dir) + .spawn()?; + let deadline = Instant::now() + Duration::from_secs(30); + let conversation = loop { + if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation")) + && !id.is_empty() + { + break id; + } + if Instant::now() > deadline { + child.kill()?; + child.wait()?; + anyhow::bail!("Conversation did not start"); + } + std::thread::yield_now(); + }; + let caller = Caller { + conversations: vec![conversation], + ..admin + }; + let id = cred.file_request( + &caller, + "restart", + GrantRequest { + items: vec![item.id], + duration_secs: None, + max_uses: None, + validator: None, + }, + )?; + let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?; + loop { + let count: i64 = db.query_row( + "SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'", + [], + |r| r.get(0), + )?; + if count == 1 { + break; + } + if Instant::now() > deadline { + child.kill()?; + child.wait()?; + anyhow::bail!("Durable wait did not start"); + } + std::thread::yield_now(); + } + child.kill()?; + child.wait()?; + cred.approve(&id, "yes", Overrides::default(), Some("test"))?; + for _ in 0..2 { + let mut resumed = Command::new(std::env::current_exe()?) + .args(["--exact", "restart_child", "--nocapture"]) + .env("DURABLE_RESTART_DIR", &dir) + .spawn()?; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if let Some(status) = resumed.try_wait()? { + assert!(status.success()); + break; + } + if Instant::now() > deadline { + resumed.kill()?; + resumed.wait()?; + anyhow::bail!("Restart did not finish"); + } + std::thread::yield_now(); + } + } + drop(db); + std::fs::remove_dir_all(dir)?; + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn proxy_child() -> anyhow::Result<()> { + let Ok(dir) = std::env::var("DURABLE_PROXY_DIR") else { + return Ok(()); + }; + let dir = std::path::Path::new(&dir); + let source = format!( + "globalThis.proxyOnly = true; const proxy = Deno.core.ops.op_model_proxy(); if (proxy.base_url !== 'https://proxy.invalid' || proxy.api_key !== 'faux-private-token') throw new Error('Wrong proxy item');\n{}", + include_str!("../js/durable-test-bundle.js") + ); + run_module( + World::open(dir)?, + sausage::process::exec(World::open(dir)?)?, + &source, + ) + .await?; + let error = run_module( + World::open(dir)?, + sausage::process::exec(World::open(dir)?)?, + "throw new Error(Deno.core.ops.op_model_proxy().api_key)", + ) + .await + .unwrap_err(); + assert!(!error.to_string().contains("faux-private-token")); + assert!(error.to_string().contains("[redacted]")); + Ok(()) +} + +#[test] +fn proxy_bootstrap_once() -> anyhow::Result<()> { + let dir = std::env::temp_dir().join(format!("sausage-durable-proxy-{}", std::process::id())); + for start in 0..2 { + let status = std::process::Command::new(std::env::current_exe()?) + .args(["--exact", "proxy_child", "--nocapture"]) + .env("DURABLE_PROXY_DIR", &dir) + .env( + "PI_PROXY_BASE_URL", + if start == 0 { + "https://proxy.invalid" + } else { + "https://ignored.invalid" + }, + ) + .env( + "PI_PROXY_API_KEY", + if start == 0 { + "faux-private-token" + } else { + "ignored-token" + }, + ) + .status()?; + assert!(status.success()); + } + let cred = Cred::open(&dir.join("host"))?; + assert_eq!( + cred.item_list()? + .iter() + .filter(|i| i.name == "llm-proxy") + .count(), + 1 + ); + for file in [ + "host/cred.sqlite", + "host/cred.sqlite-wal", + "host/harness.sqlite", + "host/harness.sqlite-wal", + ] { + if let Ok(bytes) = std::fs::read(dir.join(file)) { + assert!( + !bytes + .windows(b"faux-private-token".len()) + .any(|w| w == b"faux-private-token") + ); + } + } + assert!(!dir.join("world/conversation").exists()); + drop(cred); + std::fs::remove_dir_all(dir)?; + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn decisions_wake_idle_conversation() -> anyhow::Result<()> { + for state in ["approved", "denied", "expired"] { + let dir = + std::env::temp_dir().join(format!("sausage-durable-{}-{state}", std::process::id())); + let world = World::open(&dir)?; + let cred = Arc::new(Cred::open(&world.host_dir)?); + let admin = Caller { + pid: 0, + program: "test".into(), + conversations: vec![], + }; + let item = cred.create_item( + Item { + id: String::new(), + name: "github".into(), + version: 0, + free_use: false, + rules: vec![], + validate_js: None, + transform_js: "export default (ctx) => ctx.request".into(), + fields: vec![], + }, + &admin, + "test", + )?; + cred.file_request( + &admin, + "program", + GrantRequest { + items: vec![item.id.clone()], + duration_secs: None, + max_uses: None, + validator: None, + }, + )?; + let source = format!( + "globalThis.expectedState = {state:?};\n{}", + include_str!("../js/durable-test-bundle.js") + ); + let decide = async { + let conversation = loop { + if let Ok(id) = std::fs::read_to_string(dir.join("world/conversation")) + && !id.is_empty() + { + break id; + } + tokio::task::yield_now().await; + }; + let caller = Caller { + conversations: vec![conversation], + ..admin + }; + let id = cred.file_request( + &caller, + "test", + GrantRequest { + items: vec![item.id], + duration_secs: None, + max_uses: None, + validator: None, + }, + )?; + let harness_db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?; + loop { + let count: i64 = harness_db.query_row( + "SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'", + [], + |r| r.get(0), + )?; + if count == 1 { + break; + } + tokio::task::yield_now().await; + } + match state { + "approved" => cred.approve(&id, "yes", Overrides::default(), Some("test"))?, + "denied" => cred.deny(&id, "no", Some("test"))?, + _ => { + let db = rusqlite::Connection::open(dir.join("host/cred.sqlite"))?; + db.execute("UPDATE requests SET expires=0 WHERE id=?", [&id])?; + } + } + Ok::<_, anyhow::Error>(()) + }; + let exec = sausage::process::exec(World::open(&dir)?)?; + tokio::time::timeout(std::time::Duration::from_secs(20), async { + let (a, b) = tokio::join!( + run_module_with_credentials(world, Arc::clone(&exec), cred.clone(), &source), + decide + ); + a?; + b?; + Ok::<_, anyhow::Error>(()) + }) + .await??; + let db = rusqlite::Connection::open(dir.join("host/harness.sqlite"))?; + let tasks: i64 = db.query_row( + "SELECT count(*) FROM tasks WHERE json_extract(kind, '$')='sausage.credential.wait'", + [], + |r| r.get(0), + )?; + assert_eq!(tasks, 1, "Program requests must create no durable task"); + drop(db); + std::fs::remove_dir_all(dir)?; + } + Ok(()) +} diff --git a/host/tests/p1.rs b/host/tests/p1.rs index ade9671..65fd7f9 100644 --- a/host/tests/p1.rs +++ b/host/tests/p1.rs @@ -1,13 +1,13 @@ -//! P1 integration: a read-only bundle and real shell calls from the durable harness. +//! P1 integration: independent bundle copies and real shell calls from the durable harness. use sausage::{harness::run_module, world::World}; use sausage_vfs::Open; use std::path::PathBuf; #[tokio::test(flavor = "current_thread")] -async fn bundle_mount_and_real_shell() -> anyhow::Result<()> { +async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".."); - let bundle = repo.join("target/userland/bin"); - if !bundle.join("sh").exists() { + let bundle = repo.join("target/userland/bundle"); + if !bundle.join("bin/sh.wasm").exists() { assert!( std::process::Command::new("nu") .env_remove("CARGO_TARGET_DIR") @@ -18,9 +18,16 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> { } let dir = tempfile::tempdir()?; let world = World::open_with_userland(dir.path(), Some(&bundle))?; - assert!(world.root.stat("/bin/sh", true).await?.size > 0); + assert!(world.root.stat("/bin/sh.wasm", true).await?.size > 0); + use std::os::unix::fs::MetadataExt; + for entry in std::fs::read_dir(dir.path().join("world/bin"))? { + let entry = entry?; + assert_eq!(entry.path().extension().unwrap(), "wasm"); + assert_eq!(entry.metadata()?.nlink(), 1); + assert!(!entry.file_type()?.is_symlink()); + } assert!(world.root.stat("/usr/local/bin", true).await?.directory); - let denied = world + let writable = world .root .open( "/bin/forbidden", @@ -31,17 +38,72 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> { }, ) .await; - assert!(matches!(denied, Err(e) if e.kind() == std::io::ErrorKind::ReadOnlyFilesystem)); + assert!(writable.is_ok()); let exec = sausage::process::exec(World { root: world.root.clone(), host_dir: world.host_dir.clone(), processes: world.processes.clone(), })?; + use sausage::exec::{Command, Options, Stream}; + for (script, expected) in [ + ("command -v ls", "/bin/ls.wasm\n"), + ("ls", "bin"), + ("git --version", "git version"), + ("printf 'a\\n' | xargs echo", "a\n"), + ("env FOO=1 printenv FOO", "1\n"), + ] { + let mut out = Vec::new(); + let code = exec + .exec( + Command::Shell(script.into()), + Options::default(), + &mut |stream, bytes| { + if matches!(stream, Stream::Stdout) { + out.extend_from_slice(bytes); + } + }, + ) + .await?; + assert_eq!(code, 0, "{script}"); + assert!(String::from_utf8(out)?.contains(expected), "{script}"); + } + let mut history = Vec::new(); + assert_eq!( + exec.exec( + Command::Shell("cd /src/sausage && git log --oneline".into()), + Options::default(), + &mut |stream, bytes| { + if matches!(stream, Stream::Stdout) { + history.extend_from_slice(bytes); + } + }, + ) + .await?, + 0, + ); + let history = String::from_utf8(history)?; + assert_eq!(history.lines().count(), 1); + assert!(history.contains("Bundle source ")); + let mut status = Vec::new(); + assert_eq!( + exec.exec( + Command::Shell("cd /src/sausage && git status --short".into()), + Options::default(), + &mut |stream, bytes| { + if matches!(stream, Stream::Stdout) { + status.extend_from_slice(bytes); + } + }, + ) + .await?, + 0, + ); + assert!(status.is_empty(), "{}", String::from_utf8_lossy(&status)); let source = format!( "globalThis.testP1 = true;\n{}", include_str!("../js/test-bundle.js") ); - run_module(world, exec, &source).await?; + run_module(world, exec.clone(), &source).await?; assert_eq!( std::fs::read_to_string(dir.path().join("world/test.txt"))?, "after\n" @@ -59,6 +121,50 @@ async fn bundle_mount_and_real_shell() -> anyhow::Result<()> { let actual = std::fs::read_to_string(spills[0].path())?; let expected: String = (1..=10000).map(|n| format!("{n}\n")).collect(); assert_eq!(actual, expected); + let source_cat = std::fs::read(bundle.join("bin/cat.wasm"))?; + let head = std::fs::read(dir.path().join("world/bin/head.wasm"))?; + let mut out = Vec::new(); + assert_eq!( + exec.exec( + Command::Shell( + "cp /bin/true.wasm /bin/cat.wasm; cat /test.txt; head -n 1 /test.txt".into() + ), + Options::default(), + &mut |stream, bytes| { + if matches!(stream, Stream::Stdout) { + out.extend_from_slice(bytes); + } + } + ) + .await?, + 0 + ); + assert_eq!(out, b"after\n"); + assert_eq!(std::fs::read(bundle.join("bin/cat.wasm"))?, source_cat); + assert_eq!(std::fs::read(dir.path().join("world/bin/head.wasm"))?, head); + assert_eq!( + exec.exec( + Command::Shell("cp /bin/true.wasm /usr/local/bin/head.wasm; head /test.txt".into()), + Options::default(), + &mut |_, bytes| assert!(bytes.is_empty()) + ) + .await?, + 0 + ); + let changed = std::fs::read(dir.path().join("world/bin/cat.wasm"))?; + let reopened = World::open_with_userland( + dir.path(), + Some(std::path::Path::new("/nonexistent-bundle")), + )?; + assert_eq!( + reopened + .root + .open("/bin/cat.wasm", Open::default()) + .await? + .read(0, changed.len()) + .await?, + changed + ); // An unbundled world remains usable by process tests and file-only callers. let unbundled = tempfile::tempdir()?; let world = World::open(unbundled.path())?; diff --git a/host/tests/process.rs b/host/tests/process.rs index 1a9d6d1..9b882ec 100644 --- a/host/tests/process.rs +++ b/host/tests/process.rs @@ -9,6 +9,39 @@ use std::{ sync::{Arc, Once}, time::Duration, }; +#[test] +fn world_copies_programs_and_nested_source_only_once() -> anyhow::Result<()> { + let temp = tempfile::tempdir()?; + let bundle = temp.path().join("bundle"); + std::fs::create_dir_all(bundle.join("bin"))?; + std::fs::create_dir_all(bundle.join("src/sausage/.git"))?; + std::fs::write(bundle.join("bin/probe.wasm"), b"program")?; + std::fs::write( + bundle.join("src/sausage/.git/HEAD"), + b"ref: refs/heads/main\n", + )?; + std::fs::write(bundle.join("src/sausage/README.md"), b"source")?; + let path = temp.path().join("world"); + let _world = World::open_with_userland(&path, Some(&bundle))?; + assert_eq!( + std::fs::read(path.join("world/bin/probe.wasm"))?, + b"program" + ); + assert_eq!( + std::fs::read(path.join("world/src/sausage/README.md"))?, + b"source" + ); + assert!(path.join("world/src/sausage/.git/HEAD").is_file()); + std::fs::write(path.join("world/src/sausage/README.md"), b"agent patch")?; + let _world = World::open_with_userland(&path, Some(Path::new("/missing-bundle")))?; + assert_eq!( + std::fs::read(path.join("world/src/sausage/README.md"))?, + b"agent patch" + ); + assert!(!path.join("host/compile-cache").exists()); + Ok(()) +} + mod features { //! Acceptance coverage for preview1, identity, inherited IO, services, HTTP, and bundle aliases. use super::*; @@ -253,28 +286,32 @@ mod features { empty(&processes).await; } #[tokio::test(flavor = "multi_thread")] - async fn relative_aliases_share_compilation() { + async fn independent_copies_share_compilation() { let _serial = SERIAL.lock().await; let (temp, runtime, _) = world(); let bin = temp.path().join("world/bin"); - for name in ["true", "echo"] { - std::os::unix::fs::symlink("cat", bin.join(name)).unwrap(); + for name in ["read-a.wasm", "read-b.wasm"] { + std::fs::copy(bin.join("cat.wasm"), bin.join(name)).unwrap(); } assert_eq!( - run(&runtime, Command::Argv(vec!["/bin/true".into()]), "/") - .await - .0, + run( + &runtime, + Command::Argv(vec!["/bin/read-a".into(), "/f".into()]), + "/" + ) + .await + .0, 0 ); assert_eq!(runtime.compiled_programs(), 1); let (code, out, _) = run( &runtime, - Command::Argv(vec!["/bin/echo".into(), "shared".into()]), + Command::Argv(vec!["/bin/read-b".into(), "/f".into()]), "/", ) .await; assert_eq!(code, 0); - assert_eq!(out, b"shared\n"); + assert_eq!(out, b"root\n"); assert_eq!(runtime.compiled_programs(), 1); } #[tokio::test(flavor = "multi_thread")] @@ -402,13 +439,17 @@ fn world() -> (tempfile::TempDir, Arc, sausage::process::Processes) { let world = World::open(temp.path()).unwrap(); let cache = Path::new(env!("CARGO_TARGET_TMPDIR")).join("process-host/compile-cache"); std::fs::create_dir_all(&cache).unwrap(); - std::os::unix::fs::symlink(cache, world.host_dir.join("compile-cache")).unwrap(); + let processes = world.processes.clone(); fixture(&temp.path().join("world")); let artifacts = repo().join("userland/target/wasm32-wasip2/release"); let bin = temp.path().join("world/bin"); for tool in ["cat", "tr", "ls", "wc", "yes", "head", "sleep"] { - std::fs::copy(artifacts.join("sausage-coreutils.wasm"), bin.join(tool)).unwrap(); + std::fs::copy( + artifacts.join(format!("{tool}.wasm")), + bin.join(format!("{tool}.wasm")), + ) + .unwrap(); } for tool in ["ps", "kill"] { std::fs::copy(artifacts.join("sausage-procps.wasm"), bin.join(tool)).unwrap(); @@ -429,7 +470,11 @@ fn world() -> (tempfile::TempDir, Arc, sausage::process::Processes) { bin.join("git"), ) .unwrap(); - (temp, Runtime::new(world).unwrap(), processes) + ( + temp, + Runtime::new_with_cache(world, &cache).unwrap(), + processes, + ) } async fn run(runtime: &Arc, command: Command, cwd: &str) -> (i32, Vec, Vec) { let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); diff --git a/userland/Cargo.lock b/userland/Cargo.lock index 1ec69bc..18a2fcc 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -163,6 +174,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -179,6 +196,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bigdecimal" version = "0.4.11" @@ -251,7 +274,16 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", ] [[package]] @@ -348,7 +380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -365,6 +397,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.6.7" @@ -541,6 +583,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -568,6 +616,15 @@ dependencies = [ "libm", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -584,7 +641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" dependencies = [ "digest 0.10.7", - "spin", + "spin 0.10.1", ] [[package]] @@ -636,6 +693,18 @@ 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" @@ -655,6 +724,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -666,6 +744,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -746,6 +851,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "diff" version = "0.1.13" @@ -782,7 +897,10 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ + "block-buffer 0.10.4", + "const-oid", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -791,7 +909,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "crypto-common 0.2.2", ] @@ -862,12 +980,66 @@ 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 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.10.9", + "subtle", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[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 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1031,6 +1203,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -1202,6 +1390,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1256,6 +1457,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1292,6 +1494,7 @@ dependencies = [ "gix-pack", "gix-transport", "gix-zlib", + "sausage-ssh", "serde", "serde_json", "url", @@ -2316,6 +2519,17 @@ dependencies = [ "memmap2", ] +[[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 = "half" version = "2.7.1" @@ -2407,6 +2621,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a7763b98ba8a24f59e698bf9ab197e7676c640d6455d1580b4ce7dc560f0f0d" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hostname" version = "0.4.2" @@ -2825,6 +3048,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "intl-memoizer" version = "0.5.3" @@ -3039,7 +3271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -3062,6 +3294,15 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin 0.9.9", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -3325,6 +3566,22 @@ dependencies = [ "rand 0.8.8", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-integer" version = "0.1.47" @@ -3334,6 +3591,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.1" @@ -3368,6 +3635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3437,6 +3705,50 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[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 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3493,6 +3805,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3544,6 +3865,27 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3645,6 +3987,15 @@ dependencies = [ "syn 2.0.119", ] +[[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.5.0" @@ -3848,6 +4199,16 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ripgrep" version = "15.2.0" @@ -3873,12 +4234,42 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.6" @@ -4078,12 +4469,47 @@ dependencies = [ "wit-bindgen 0.46.0", ] +[[package]] +name = "sausage-ssh" +version = "0.1.0" +dependencies = [ + "aes", + "base64", + "ctr", + "futures-lite", + "hmac", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha1 0.10.7", + "sha2 0.10.9", + "signature", + "ssh-key", + "url", + "wasi 0.14.7+wasi-0.2.4", + "x25519-dalek", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[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 = "sed" version = "0.3.0" @@ -4163,6 +4589,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -4170,7 +4607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -4180,6 +4617,17 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5b0ebf6537e3e8db8bb7d21ecb6d7399738c953c757945c6b95e46c4e95df7b" +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.1" @@ -4187,7 +4635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -4225,6 +4673,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4270,18 +4728,84 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spin" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sponge-cursor" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" +[[package]] +name = "ssh" +version = "0.1.0" +dependencies = [ + "clap", + "sausage-ssh", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2 0.10.9", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4304,6 +4828,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -5733,8 +6263,8 @@ dependencies = [ "procfs", "rustc-hash", "rustix", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.1", "sha3", "shake", "sm3", @@ -6226,6 +6756,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xattr" version = "1.7.0" @@ -6312,6 +6854,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/userland/Cargo.toml b/userland/Cargo.toml index b774fb9..debb667 100644 --- a/userland/Cargo.toml +++ b/userland/Cargo.toml @@ -1,7 +1,7 @@ # The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's. [workspace] resolver = "3" -members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "init"] +members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "init"] # WASI compatibility patches: git/vendor/PATCHES.md. [patch.crates-io] diff --git a/userland/brush/build.nu b/userland/brush/build.nu index 46213f3..da7908b 100644 --- a/userland/brush/build.nu +++ b/userland/brush/build.nu @@ -18,6 +18,12 @@ def main [] { if (^git -C $source rev-parse HEAD | str trim) != $revision { error make {msg: 'unexpected brush revision'} } let core = ($source | path join brush-core) copy-if-changed ($here | path join process.rs) ($core | path join src sys wasm process.rs) + copy-if-changed ($here | path join lookup.rs) ($core | path join src sys wasm sausage-lookup.rs) + let fs = ($core | path join src sys wasm fs.rs) + let fs_text = (open --raw $fs) + if not ($fs_text | str contains 'include!("sausage-lookup.rs")') { + $fs_text + "\ninclude!(\"sausage-lookup.rs\");\n" | save --force $fs + } mkdir ($core | path join sausage-process) let wit = ($here | path join .. .. host wit | path expand) copy-if-changed ($wit | path join process.wit) ($core | path join sausage-process process.wit) diff --git a/userland/brush/lookup.rs b/userland/brush/lookup.rs new file mode 100644 index 0000000..260f3f8 --- /dev/null +++ b/userland/brush/lookup.rs @@ -0,0 +1,28 @@ +/// Resolve ordinary files before the WASI suffix in each PATH folder. +pub fn resolve_executable(path: std::path::PathBuf) -> Option { + if path.is_file() { + return Some(path); + } + let mut name = path.into_os_string(); + name.push(".wasm"); + let path = std::path::PathBuf::from(name); + path.is_file().then_some(path) +} +pub(crate) fn resolve_executable_path( + path: std::path::PathBuf, + working_dir: &std::path::Path, +) -> std::path::PathBuf { + let absolute = working_dir.join(&path); + if absolute.is_file() { + return path; + } + let mut name = absolute.into_os_string(); + name.push(".wasm"); + if std::path::PathBuf::from(name).is_file() { + let mut name = path.into_os_string(); + name.push(".wasm"); + name.into() + } else { + path + } +} diff --git a/userland/bundle.nu b/userland/bundle.nu index fa8e28b..f743b15 100644 --- a/userland/bundle.nu +++ b/userland/bundle.nu @@ -1,8 +1,9 @@ #!/usr/bin/env nu -# Build every workspace CLI and assemble the read-only /bin bundle. +# Build independent commands and a committed source snapshot of their repository. def main [destination?: path] { let here = $env.FILE_PWD - let destination = ($destination | default ($here | path join .. target userland bin) | path expand) + let destination = ($destination | default ($here | path join .. target userland bundle) | path expand) + let bin = ($destination | path join bin) let object = ($here | path join cwd-init init.o) let sources = [($here | path join cwd-init init.rs) ($here | path join cwd-init exit.wit) ($here | path join cwd-init build.nu)] if not ($object | path exists) or ($sources | any {|source| (ls $source | get modified.0) > (ls $object | get modified.0) }) { @@ -14,23 +15,46 @@ def main [destination?: path] { if $env.LAST_EXIT_CODE != 0 { error make {msg: 'userland build failed'} } ^nu ($here | path join brush build.nu) if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush build failed'} } - mkdir $destination + mkdir $bin for package in ($metadata.packages | where {|p| $p.id in $metadata.workspace_members }) { let tools = ($package.manifest_path | path dirname | path join tools.txt) for binary in ($package.targets | where {|t| 'bin' in $t.kind }) { let source = ($metadata.target_directory | path join wasm32-wasip2 release $'($binary.name).wasm') let names = if ($tools | path exists) { open --raw $tools | lines | where {|s| ($s | str trim) != '' } } else { [$binary.name] } for name in $names { - ^ln -f $source ($destination | path join $name) - if $env.LAST_EXIT_CODE != 0 { error make {msg: $'bundle link failed: ($name)'} } + ^cp --remove-destination $source ($bin | path join $'($name).wasm') + if $env.LAST_EXIT_CODE != 0 { error make {msg: $'bundle copy failed: ($name)'} } } } } let brush_metadata = (^cargo metadata --manifest-path ($here | path join brush source Cargo.toml) --no-deps --format-version 1 | from json) let brush = ($brush_metadata.target_directory | path join wasm32-wasip2 release brush.wasm) for name in [sh bash] { - ^ln -f $brush ($destination | path join $name) - if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush link failed'} } + ^cp --remove-destination $brush ($bin | path join $'($name).wasm') + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush copy failed'} } + } + let repo = ($here | path join .. | path expand) + let revision = (^git -C $repo rev-parse HEAD | str trim) + let snapshot = ($destination | path join src sausage) + if ($snapshot | path exists) { rm --recursive --force $snapshot } + mkdir $snapshot + let archive = ($destination | path join source.tar) + ^git -C $repo archive --output $archive HEAD host vfs userland Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source archive failed'} } + ^tar -xf $archive -C $snapshot + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source extraction failed'} } + rm $archive + ^git -C $snapshot init --quiet --initial-branch=main + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git init failed'} } + ^git -C $snapshot add host vfs userland Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git add failed'} } + with-env { + GIT_AUTHOR_NAME: 'Sausage', GIT_AUTHOR_EMAIL: 'sausage@localhost', + GIT_COMMITTER_NAME: 'Sausage', GIT_COMMITTER_EMAIL: 'sausage@localhost', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00+0000', GIT_COMMITTER_DATE: '2000-01-01T00:00:00+0000' + } { + ^git -C $snapshot commit --no-gpg-sign --quiet -m $'Bundle source ($revision)' + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'source git commit failed'} } } print $destination } diff --git a/userland/coreutils/src/bin/arch.rs b/userland/coreutils/src/bin/arch.rs new file mode 100644 index 0000000..f682a17 --- /dev/null +++ b/userland/coreutils/src/bin/arch.rs @@ -0,0 +1,5 @@ +//! Implements the arch command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("arch").expect("utility supplies localization"); + std::process::exit(uu_arch::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/b2sum.rs b/userland/coreutils/src/bin/b2sum.rs new file mode 100644 index 0000000..d21bbd5 --- /dev/null +++ b/userland/coreutils/src/bin/b2sum.rs @@ -0,0 +1,5 @@ +//! Implements the b2sum command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("b2sum").expect("utility supplies localization"); + std::process::exit(uu_b2sum::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/base32.rs b/userland/coreutils/src/bin/base32.rs new file mode 100644 index 0000000..4a0c434 --- /dev/null +++ b/userland/coreutils/src/bin/base32.rs @@ -0,0 +1,5 @@ +//! Implements the base32 command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("base32").expect("utility supplies localization"); + std::process::exit(uu_base32::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/base64.rs b/userland/coreutils/src/bin/base64.rs new file mode 100644 index 0000000..79e0ce9 --- /dev/null +++ b/userland/coreutils/src/bin/base64.rs @@ -0,0 +1,5 @@ +//! Implements the base64 command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("base64").expect("utility supplies localization"); + std::process::exit(uu_base64::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/basename.rs b/userland/coreutils/src/bin/basename.rs new file mode 100644 index 0000000..234fc0a --- /dev/null +++ b/userland/coreutils/src/bin/basename.rs @@ -0,0 +1,5 @@ +//! Implements the basename command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("basename").expect("utility supplies localization"); + std::process::exit(uu_basename::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/basenc.rs b/userland/coreutils/src/bin/basenc.rs new file mode 100644 index 0000000..05c22fb --- /dev/null +++ b/userland/coreutils/src/bin/basenc.rs @@ -0,0 +1,5 @@ +//! Implements the basenc command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("basenc").expect("utility supplies localization"); + std::process::exit(uu_basenc::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/cat.rs b/userland/coreutils/src/bin/cat.rs new file mode 100644 index 0000000..0f4ff1b --- /dev/null +++ b/userland/coreutils/src/bin/cat.rs @@ -0,0 +1,5 @@ +//! Implements the cat command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("cat").expect("utility supplies localization"); + std::process::exit(uu_cat::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/cksum.rs b/userland/coreutils/src/bin/cksum.rs new file mode 100644 index 0000000..58294cd --- /dev/null +++ b/userland/coreutils/src/bin/cksum.rs @@ -0,0 +1,5 @@ +//! Implements the cksum command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("cksum").expect("utility supplies localization"); + std::process::exit(uu_cksum::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/comm.rs b/userland/coreutils/src/bin/comm.rs new file mode 100644 index 0000000..51ef1e5 --- /dev/null +++ b/userland/coreutils/src/bin/comm.rs @@ -0,0 +1,5 @@ +//! Implements the comm command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("comm").expect("utility supplies localization"); + std::process::exit(uu_comm::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/cp.rs b/userland/coreutils/src/bin/cp.rs new file mode 100644 index 0000000..c320dd0 --- /dev/null +++ b/userland/coreutils/src/bin/cp.rs @@ -0,0 +1,5 @@ +//! Implements the cp command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("cp").expect("utility supplies localization"); + std::process::exit(uu_cp::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/csplit.rs b/userland/coreutils/src/bin/csplit.rs new file mode 100644 index 0000000..8d24215 --- /dev/null +++ b/userland/coreutils/src/bin/csplit.rs @@ -0,0 +1,5 @@ +//! Implements the csplit command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("csplit").expect("utility supplies localization"); + std::process::exit(uu_csplit::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/cut.rs b/userland/coreutils/src/bin/cut.rs new file mode 100644 index 0000000..bc1dbf4 --- /dev/null +++ b/userland/coreutils/src/bin/cut.rs @@ -0,0 +1,5 @@ +//! Implements the cut command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("cut").expect("utility supplies localization"); + std::process::exit(uu_cut::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/date.rs b/userland/coreutils/src/bin/date.rs new file mode 100644 index 0000000..7f95526 --- /dev/null +++ b/userland/coreutils/src/bin/date.rs @@ -0,0 +1,5 @@ +//! Implements the date command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("date").expect("utility supplies localization"); + std::process::exit(uu_date::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/dd.rs b/userland/coreutils/src/bin/dd.rs new file mode 100644 index 0000000..1475ca2 --- /dev/null +++ b/userland/coreutils/src/bin/dd.rs @@ -0,0 +1,5 @@ +//! Implements the dd command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("dd").expect("utility supplies localization"); + std::process::exit(uu_dd::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/dir.rs b/userland/coreutils/src/bin/dir.rs new file mode 100644 index 0000000..76c0d22 --- /dev/null +++ b/userland/coreutils/src/bin/dir.rs @@ -0,0 +1,5 @@ +//! Implements the dir command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("ls").expect("utility supplies localization"); + std::process::exit(uu_dir::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/dircolors.rs b/userland/coreutils/src/bin/dircolors.rs new file mode 100644 index 0000000..aaa7527 --- /dev/null +++ b/userland/coreutils/src/bin/dircolors.rs @@ -0,0 +1,5 @@ +//! Implements the dircolors command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("dircolors").expect("utility supplies localization"); + std::process::exit(uu_dircolors::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/dirname.rs b/userland/coreutils/src/bin/dirname.rs new file mode 100644 index 0000000..3fe4ff4 --- /dev/null +++ b/userland/coreutils/src/bin/dirname.rs @@ -0,0 +1,5 @@ +//! Implements the dirname command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("dirname").expect("utility supplies localization"); + std::process::exit(uu_dirname::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/echo.rs b/userland/coreutils/src/bin/echo.rs new file mode 100644 index 0000000..dd479b5 --- /dev/null +++ b/userland/coreutils/src/bin/echo.rs @@ -0,0 +1,5 @@ +//! Implements the echo command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("echo").expect("utility supplies localization"); + std::process::exit(uu_echo::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/expand.rs b/userland/coreutils/src/bin/expand.rs new file mode 100644 index 0000000..3e0b0ca --- /dev/null +++ b/userland/coreutils/src/bin/expand.rs @@ -0,0 +1,5 @@ +//! Implements the expand command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("expand").expect("utility supplies localization"); + std::process::exit(uu_expand::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/expr.rs b/userland/coreutils/src/bin/expr.rs new file mode 100644 index 0000000..001ec55 --- /dev/null +++ b/userland/coreutils/src/bin/expr.rs @@ -0,0 +1,5 @@ +//! Implements the expr command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("expr").expect("utility supplies localization"); + std::process::exit(uu_expr::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/factor.rs b/userland/coreutils/src/bin/factor.rs new file mode 100644 index 0000000..71cd796 --- /dev/null +++ b/userland/coreutils/src/bin/factor.rs @@ -0,0 +1,5 @@ +//! Implements the factor command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("factor").expect("utility supplies localization"); + std::process::exit(uu_factor::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/false.rs b/userland/coreutils/src/bin/false.rs new file mode 100644 index 0000000..cf3a464 --- /dev/null +++ b/userland/coreutils/src/bin/false.rs @@ -0,0 +1,5 @@ +//! Implements the false command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("false").expect("utility supplies localization"); + std::process::exit(uu_false::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/fmt.rs b/userland/coreutils/src/bin/fmt.rs new file mode 100644 index 0000000..ad6d2ac --- /dev/null +++ b/userland/coreutils/src/bin/fmt.rs @@ -0,0 +1,5 @@ +//! Implements the fmt command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("fmt").expect("utility supplies localization"); + std::process::exit(uu_fmt::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/fold.rs b/userland/coreutils/src/bin/fold.rs new file mode 100644 index 0000000..caa0eb5 --- /dev/null +++ b/userland/coreutils/src/bin/fold.rs @@ -0,0 +1,5 @@ +//! Implements the fold command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("fold").expect("utility supplies localization"); + std::process::exit(uu_fold::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/head.rs b/userland/coreutils/src/bin/head.rs new file mode 100644 index 0000000..eb0fbf4 --- /dev/null +++ b/userland/coreutils/src/bin/head.rs @@ -0,0 +1,5 @@ +//! Implements the head command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("head").expect("utility supplies localization"); + std::process::exit(uu_head::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/hostid.rs b/userland/coreutils/src/bin/hostid.rs new file mode 100644 index 0000000..4966820 --- /dev/null +++ b/userland/coreutils/src/bin/hostid.rs @@ -0,0 +1,5 @@ +//! Implements the hostid command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("hostid").expect("utility supplies localization"); + std::process::exit(uu_hostid::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/join.rs b/userland/coreutils/src/bin/join.rs new file mode 100644 index 0000000..3831cc4 --- /dev/null +++ b/userland/coreutils/src/bin/join.rs @@ -0,0 +1,5 @@ +//! Implements the join command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("join").expect("utility supplies localization"); + std::process::exit(uu_join::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/link.rs b/userland/coreutils/src/bin/link.rs new file mode 100644 index 0000000..956480f --- /dev/null +++ b/userland/coreutils/src/bin/link.rs @@ -0,0 +1,5 @@ +//! Implements the link command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("link").expect("utility supplies localization"); + std::process::exit(uu_link::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/ln.rs b/userland/coreutils/src/bin/ln.rs new file mode 100644 index 0000000..c893121 --- /dev/null +++ b/userland/coreutils/src/bin/ln.rs @@ -0,0 +1,5 @@ +//! Implements the ln command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("ln").expect("utility supplies localization"); + std::process::exit(uu_ln::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/ls.rs b/userland/coreutils/src/bin/ls.rs new file mode 100644 index 0000000..5545e9e --- /dev/null +++ b/userland/coreutils/src/bin/ls.rs @@ -0,0 +1,5 @@ +//! Implements the ls command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("ls").expect("utility supplies localization"); + std::process::exit(uu_ls::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/md5sum.rs b/userland/coreutils/src/bin/md5sum.rs new file mode 100644 index 0000000..735797e --- /dev/null +++ b/userland/coreutils/src/bin/md5sum.rs @@ -0,0 +1,5 @@ +//! Implements the md5sum command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("md5sum").expect("utility supplies localization"); + std::process::exit(uu_md5sum::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/mkdir.rs b/userland/coreutils/src/bin/mkdir.rs new file mode 100644 index 0000000..c2f8923 --- /dev/null +++ b/userland/coreutils/src/bin/mkdir.rs @@ -0,0 +1,5 @@ +//! Implements the mkdir command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("mkdir").expect("utility supplies localization"); + std::process::exit(uu_mkdir::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/mktemp.rs b/userland/coreutils/src/bin/mktemp.rs new file mode 100644 index 0000000..71e0781 --- /dev/null +++ b/userland/coreutils/src/bin/mktemp.rs @@ -0,0 +1,5 @@ +//! Implements the mktemp command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("mktemp").expect("utility supplies localization"); + std::process::exit(uu_mktemp::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/mv.rs b/userland/coreutils/src/bin/mv.rs new file mode 100644 index 0000000..f62bfb1 --- /dev/null +++ b/userland/coreutils/src/bin/mv.rs @@ -0,0 +1,5 @@ +//! Implements the mv command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("mv").expect("utility supplies localization"); + std::process::exit(uu_mv::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/nice.rs b/userland/coreutils/src/bin/nice.rs new file mode 100644 index 0000000..b31f52d --- /dev/null +++ b/userland/coreutils/src/bin/nice.rs @@ -0,0 +1,5 @@ +//! Implements the nice command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("nice").expect("utility supplies localization"); + std::process::exit(uu_nice::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/nl.rs b/userland/coreutils/src/bin/nl.rs new file mode 100644 index 0000000..0a50d3c --- /dev/null +++ b/userland/coreutils/src/bin/nl.rs @@ -0,0 +1,5 @@ +//! Implements the nl command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("nl").expect("utility supplies localization"); + std::process::exit(uu_nl::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/nproc.rs b/userland/coreutils/src/bin/nproc.rs new file mode 100644 index 0000000..fcc81f2 --- /dev/null +++ b/userland/coreutils/src/bin/nproc.rs @@ -0,0 +1,5 @@ +//! Implements the nproc command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("nproc").expect("utility supplies localization"); + std::process::exit(uu_nproc::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/numfmt.rs b/userland/coreutils/src/bin/numfmt.rs new file mode 100644 index 0000000..07829ae --- /dev/null +++ b/userland/coreutils/src/bin/numfmt.rs @@ -0,0 +1,5 @@ +//! Implements the numfmt command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("numfmt").expect("utility supplies localization"); + std::process::exit(uu_numfmt::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/od.rs b/userland/coreutils/src/bin/od.rs new file mode 100644 index 0000000..cab0501 --- /dev/null +++ b/userland/coreutils/src/bin/od.rs @@ -0,0 +1,5 @@ +//! Implements the od command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("od").expect("utility supplies localization"); + std::process::exit(uu_od::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/paste.rs b/userland/coreutils/src/bin/paste.rs new file mode 100644 index 0000000..17559db --- /dev/null +++ b/userland/coreutils/src/bin/paste.rs @@ -0,0 +1,5 @@ +//! Implements the paste command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("paste").expect("utility supplies localization"); + std::process::exit(uu_paste::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/pathchk.rs b/userland/coreutils/src/bin/pathchk.rs new file mode 100644 index 0000000..0a919d9 --- /dev/null +++ b/userland/coreutils/src/bin/pathchk.rs @@ -0,0 +1,5 @@ +//! Implements the pathchk command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("pathchk").expect("utility supplies localization"); + std::process::exit(uu_pathchk::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/pr.rs b/userland/coreutils/src/bin/pr.rs new file mode 100644 index 0000000..0ca52d0 --- /dev/null +++ b/userland/coreutils/src/bin/pr.rs @@ -0,0 +1,5 @@ +//! Implements the pr command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("pr").expect("utility supplies localization"); + std::process::exit(uu_pr::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/printenv.rs b/userland/coreutils/src/bin/printenv.rs new file mode 100644 index 0000000..a3f3800 --- /dev/null +++ b/userland/coreutils/src/bin/printenv.rs @@ -0,0 +1,5 @@ +//! Implements the printenv command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("printenv").expect("utility supplies localization"); + std::process::exit(uu_printenv::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/printf.rs b/userland/coreutils/src/bin/printf.rs new file mode 100644 index 0000000..720c8a7 --- /dev/null +++ b/userland/coreutils/src/bin/printf.rs @@ -0,0 +1,5 @@ +//! Implements the printf command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("printf").expect("utility supplies localization"); + std::process::exit(uu_printf::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/ptx.rs b/userland/coreutils/src/bin/ptx.rs new file mode 100644 index 0000000..a5d7fa7 --- /dev/null +++ b/userland/coreutils/src/bin/ptx.rs @@ -0,0 +1,5 @@ +//! Implements the ptx command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("ptx").expect("utility supplies localization"); + std::process::exit(uu_ptx::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/pwd.rs b/userland/coreutils/src/bin/pwd.rs new file mode 100644 index 0000000..c62049f --- /dev/null +++ b/userland/coreutils/src/bin/pwd.rs @@ -0,0 +1,5 @@ +//! Implements the pwd command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("pwd").expect("utility supplies localization"); + std::process::exit(uu_pwd::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/readlink.rs b/userland/coreutils/src/bin/readlink.rs new file mode 100644 index 0000000..5fb109b --- /dev/null +++ b/userland/coreutils/src/bin/readlink.rs @@ -0,0 +1,5 @@ +//! Implements the readlink command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("readlink").expect("utility supplies localization"); + std::process::exit(uu_readlink::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/realpath.rs b/userland/coreutils/src/bin/realpath.rs new file mode 100644 index 0000000..fb4e428 --- /dev/null +++ b/userland/coreutils/src/bin/realpath.rs @@ -0,0 +1,5 @@ +//! Implements the realpath command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("realpath").expect("utility supplies localization"); + std::process::exit(uu_realpath::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/rm.rs b/userland/coreutils/src/bin/rm.rs new file mode 100644 index 0000000..f1a3549 --- /dev/null +++ b/userland/coreutils/src/bin/rm.rs @@ -0,0 +1,5 @@ +//! Implements the rm command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("rm").expect("utility supplies localization"); + std::process::exit(uu_rm::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/rmdir.rs b/userland/coreutils/src/bin/rmdir.rs new file mode 100644 index 0000000..ee5edc1 --- /dev/null +++ b/userland/coreutils/src/bin/rmdir.rs @@ -0,0 +1,5 @@ +//! Implements the rmdir command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("rmdir").expect("utility supplies localization"); + std::process::exit(uu_rmdir::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/seq.rs b/userland/coreutils/src/bin/seq.rs new file mode 100644 index 0000000..b6ed06d --- /dev/null +++ b/userland/coreutils/src/bin/seq.rs @@ -0,0 +1,5 @@ +//! Implements the seq command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("seq").expect("utility supplies localization"); + std::process::exit(uu_seq::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/sha1sum.rs b/userland/coreutils/src/bin/sha1sum.rs new file mode 100644 index 0000000..c601c6e --- /dev/null +++ b/userland/coreutils/src/bin/sha1sum.rs @@ -0,0 +1,5 @@ +//! Implements the sha1sum command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("sha1sum").expect("utility supplies localization"); + std::process::exit(uu_sha1sum::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/sha224sum.rs b/userland/coreutils/src/bin/sha224sum.rs new file mode 100644 index 0000000..96be305 --- /dev/null +++ b/userland/coreutils/src/bin/sha224sum.rs @@ -0,0 +1,5 @@ +//! Implements the sha224sum command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("sha224sum").expect("utility supplies localization"); + std::process::exit(uu_sha224sum::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/sha256sum.rs b/userland/coreutils/src/bin/sha256sum.rs new file mode 100644 index 0000000..d8935f8 --- /dev/null +++ b/userland/coreutils/src/bin/sha256sum.rs @@ -0,0 +1,5 @@ +//! Implements the sha256sum command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("sha256sum").expect("utility supplies localization"); + std::process::exit(uu_sha256sum::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/sha384sum.rs b/userland/coreutils/src/bin/sha384sum.rs new file mode 100644 index 0000000..8897db2 --- /dev/null +++ b/userland/coreutils/src/bin/sha384sum.rs @@ -0,0 +1,5 @@ +//! Implements the sha384sum command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("sha384sum").expect("utility supplies localization"); + std::process::exit(uu_sha384sum::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/sha512sum.rs b/userland/coreutils/src/bin/sha512sum.rs new file mode 100644 index 0000000..2cdcc6e --- /dev/null +++ b/userland/coreutils/src/bin/sha512sum.rs @@ -0,0 +1,5 @@ +//! Implements the sha512sum command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("sha512sum").expect("utility supplies localization"); + std::process::exit(uu_sha512sum::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/shred.rs b/userland/coreutils/src/bin/shred.rs new file mode 100644 index 0000000..55b5aa8 --- /dev/null +++ b/userland/coreutils/src/bin/shred.rs @@ -0,0 +1,5 @@ +//! Implements the shred command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("shred").expect("utility supplies localization"); + std::process::exit(uu_shred::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/shuf.rs b/userland/coreutils/src/bin/shuf.rs new file mode 100644 index 0000000..641ee63 --- /dev/null +++ b/userland/coreutils/src/bin/shuf.rs @@ -0,0 +1,5 @@ +//! Implements the shuf command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("shuf").expect("utility supplies localization"); + std::process::exit(uu_shuf::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/sleep.rs b/userland/coreutils/src/bin/sleep.rs new file mode 100644 index 0000000..0ab1a97 --- /dev/null +++ b/userland/coreutils/src/bin/sleep.rs @@ -0,0 +1,5 @@ +//! Implements the sleep command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("sleep").expect("utility supplies localization"); + std::process::exit(uu_sleep::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/sort.rs b/userland/coreutils/src/bin/sort.rs new file mode 100644 index 0000000..c89f40e --- /dev/null +++ b/userland/coreutils/src/bin/sort.rs @@ -0,0 +1,5 @@ +//! Implements the sort command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("sort").expect("utility supplies localization"); + std::process::exit(uu_sort::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/split.rs b/userland/coreutils/src/bin/split.rs new file mode 100644 index 0000000..737e83f --- /dev/null +++ b/userland/coreutils/src/bin/split.rs @@ -0,0 +1,5 @@ +//! Implements the split command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("split").expect("utility supplies localization"); + std::process::exit(uu_split::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/sum.rs b/userland/coreutils/src/bin/sum.rs new file mode 100644 index 0000000..9b80e68 --- /dev/null +++ b/userland/coreutils/src/bin/sum.rs @@ -0,0 +1,5 @@ +//! Implements the sum command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("sum").expect("utility supplies localization"); + std::process::exit(uu_sum::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/tail.rs b/userland/coreutils/src/bin/tail.rs new file mode 100644 index 0000000..b497795 --- /dev/null +++ b/userland/coreutils/src/bin/tail.rs @@ -0,0 +1,5 @@ +//! Implements the tail command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("tail").expect("utility supplies localization"); + std::process::exit(uu_tail::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/tee.rs b/userland/coreutils/src/bin/tee.rs new file mode 100644 index 0000000..42bae96 --- /dev/null +++ b/userland/coreutils/src/bin/tee.rs @@ -0,0 +1,5 @@ +//! Implements the tee command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("tee").expect("utility supplies localization"); + std::process::exit(uu_tee::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/test.rs b/userland/coreutils/src/bin/test.rs new file mode 100644 index 0000000..8a9f8f4 --- /dev/null +++ b/userland/coreutils/src/bin/test.rs @@ -0,0 +1,5 @@ +//! Implements the test command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("test").expect("utility supplies localization"); + std::process::exit(uu_test::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/touch.rs b/userland/coreutils/src/bin/touch.rs new file mode 100644 index 0000000..d81624a --- /dev/null +++ b/userland/coreutils/src/bin/touch.rs @@ -0,0 +1,5 @@ +//! Implements the touch command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("touch").expect("utility supplies localization"); + std::process::exit(uu_touch::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/tr.rs b/userland/coreutils/src/bin/tr.rs new file mode 100644 index 0000000..ddf2acb --- /dev/null +++ b/userland/coreutils/src/bin/tr.rs @@ -0,0 +1,5 @@ +//! Implements the tr command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("tr").expect("utility supplies localization"); + std::process::exit(uu_tr::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/true.rs b/userland/coreutils/src/bin/true.rs new file mode 100644 index 0000000..63979b6 --- /dev/null +++ b/userland/coreutils/src/bin/true.rs @@ -0,0 +1,5 @@ +//! Implements the true command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("true").expect("utility supplies localization"); + std::process::exit(uu_true::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/truncate.rs b/userland/coreutils/src/bin/truncate.rs new file mode 100644 index 0000000..5bca57f --- /dev/null +++ b/userland/coreutils/src/bin/truncate.rs @@ -0,0 +1,5 @@ +//! Implements the truncate command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("truncate").expect("utility supplies localization"); + std::process::exit(uu_truncate::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/tsort.rs b/userland/coreutils/src/bin/tsort.rs new file mode 100644 index 0000000..7babdb8 --- /dev/null +++ b/userland/coreutils/src/bin/tsort.rs @@ -0,0 +1,5 @@ +//! Implements the tsort command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("tsort").expect("utility supplies localization"); + std::process::exit(uu_tsort::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/tty.rs b/userland/coreutils/src/bin/tty.rs new file mode 100644 index 0000000..aeab75f --- /dev/null +++ b/userland/coreutils/src/bin/tty.rs @@ -0,0 +1,5 @@ +//! Implements the tty command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("tty").expect("utility supplies localization"); + std::process::exit(uu_tty::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/uname.rs b/userland/coreutils/src/bin/uname.rs new file mode 100644 index 0000000..a8c8953 --- /dev/null +++ b/userland/coreutils/src/bin/uname.rs @@ -0,0 +1,5 @@ +//! Implements the uname command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("uname").expect("utility supplies localization"); + std::process::exit(uu_uname::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/unexpand.rs b/userland/coreutils/src/bin/unexpand.rs new file mode 100644 index 0000000..7753075 --- /dev/null +++ b/userland/coreutils/src/bin/unexpand.rs @@ -0,0 +1,5 @@ +//! Implements the unexpand command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("unexpand").expect("utility supplies localization"); + std::process::exit(uu_unexpand::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/uniq.rs b/userland/coreutils/src/bin/uniq.rs new file mode 100644 index 0000000..5891b29 --- /dev/null +++ b/userland/coreutils/src/bin/uniq.rs @@ -0,0 +1,5 @@ +//! Implements the uniq command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("uniq").expect("utility supplies localization"); + std::process::exit(uu_uniq::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/unlink.rs b/userland/coreutils/src/bin/unlink.rs new file mode 100644 index 0000000..0249957 --- /dev/null +++ b/userland/coreutils/src/bin/unlink.rs @@ -0,0 +1,5 @@ +//! Implements the unlink command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("unlink").expect("utility supplies localization"); + std::process::exit(uu_unlink::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/vdir.rs b/userland/coreutils/src/bin/vdir.rs new file mode 100644 index 0000000..c3b426d --- /dev/null +++ b/userland/coreutils/src/bin/vdir.rs @@ -0,0 +1,5 @@ +//! Implements the vdir command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("ls").expect("utility supplies localization"); + std::process::exit(uu_vdir::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/wc.rs b/userland/coreutils/src/bin/wc.rs new file mode 100644 index 0000000..b866eaf --- /dev/null +++ b/userland/coreutils/src/bin/wc.rs @@ -0,0 +1,5 @@ +//! Implements the wc command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("wc").expect("utility supplies localization"); + std::process::exit(uu_wc::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/bin/yes.rs b/userland/coreutils/src/bin/yes.rs new file mode 100644 index 0000000..ac14fcf --- /dev/null +++ b/userland/coreutils/src/bin/yes.rs @@ -0,0 +1,5 @@ +//! Implements the yes command as an independent WASI binary. +fn main() { + uucore::locale::setup_localization("yes").expect("utility supplies localization"); + std::process::exit(uu_yes::uumain(uucore::args_os())); +} \ No newline at end of file diff --git a/userland/coreutils/src/main.rs b/userland/coreutils/src/main.rs deleted file mode 100644 index c77947d..0000000 --- a/userland/coreutils/src/main.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Dispatches the portable uutils 0.12 multicall component by argv[0]. -fn main() { - let argv0 = std::env::args().next().unwrap(); - let name = std::path::Path::new(&argv0) - .file_name() - .unwrap() - .to_str() - .unwrap(); - let name = if name == "[" { "test" } else { name }; - let locale = match name { - "dir" | "vdir" => "ls", - _ => name, - }; - uucore::locale::setup_localization(locale).expect("utility supplies localization"); - let args = uucore::args_os(); - let code = match name { - "arch" => uu_arch::uumain(args), - "base32" => uu_base32::uumain(args), - "base64" => uu_base64::uumain(args), - "basename" => uu_basename::uumain(args), - "basenc" => uu_basenc::uumain(args), - "cat" => uu_cat::uumain(args), - "cksum" => uu_cksum::uumain(args), - "b2sum" => uu_b2sum::uumain(args), - "md5sum" => uu_md5sum::uumain(args), - "nice" => uu_nice::uumain(args), - "sha1sum" => uu_sha1sum::uumain(args), - "sha224sum" => uu_sha224sum::uumain(args), - "sha256sum" => uu_sha256sum::uumain(args), - "sha384sum" => uu_sha384sum::uumain(args), - "sha512sum" => uu_sha512sum::uumain(args), - "comm" => uu_comm::uumain(args), - "cp" => uu_cp::uumain(args), - "csplit" => uu_csplit::uumain(args), - "cut" => uu_cut::uumain(args), - "date" => uu_date::uumain(args), - "dd" => uu_dd::uumain(args), - "dir" => uu_dir::uumain(args), - "dircolors" => uu_dircolors::uumain(args), - "dirname" => uu_dirname::uumain(args), - "echo" => uu_echo::uumain(args), - "expand" => uu_expand::uumain(args), - "expr" => uu_expr::uumain(args), - "factor" => uu_factor::uumain(args), - "false" => uu_false::uumain(args), - "fmt" => uu_fmt::uumain(args), - "fold" => uu_fold::uumain(args), - "head" => uu_head::uumain(args), - "hostid" => uu_hostid::uumain(args), - "join" => uu_join::uumain(args), - "link" => uu_link::uumain(args), - "ln" => uu_ln::uumain(args), - "ls" => uu_ls::uumain(args), - "mkdir" => uu_mkdir::uumain(args), - "mktemp" => uu_mktemp::uumain(args), - "mv" => uu_mv::uumain(args), - "nl" => uu_nl::uumain(args), - "numfmt" => uu_numfmt::uumain(args), - "od" => uu_od::uumain(args), - "paste" => uu_paste::uumain(args), - "pathchk" => uu_pathchk::uumain(args), - "pr" => uu_pr::uumain(args), - "printenv" => uu_printenv::uumain(args), - "printf" => uu_printf::uumain(args), - "ptx" => uu_ptx::uumain(args), - "pwd" => uu_pwd::uumain(args), - "readlink" => uu_readlink::uumain(args), - "realpath" => uu_realpath::uumain(args), - "rm" => uu_rm::uumain(args), - "rmdir" => uu_rmdir::uumain(args), - "seq" => uu_seq::uumain(args), - "shred" => uu_shred::uumain(args), - "shuf" => uu_shuf::uumain(args), - "sleep" => uu_sleep::uumain(args), - "sort" => uu_sort::uumain(args), - "split" => uu_split::uumain(args), - "sum" => uu_sum::uumain(args), - "tail" => uu_tail::uumain(args), - "tee" => uu_tee::uumain(args), - "test" => uu_test::uumain(args), - "touch" => uu_touch::uumain(args), - "tr" => uu_tr::uumain(args), - "true" => uu_true::uumain(args), - "truncate" => uu_truncate::uumain(args), - "tsort" => uu_tsort::uumain(args), - "tty" => uu_tty::uumain(args), - "uname" => uu_uname::uumain(args), - "unexpand" => uu_unexpand::uumain(args), - "uniq" => uu_uniq::uumain(args), - "unlink" => uu_unlink::uumain(args), - "vdir" => uu_vdir::uumain(args), - "wc" => uu_wc::uumain(args), - "yes" => uu_yes::uumain(args), - "nproc" => uu_nproc::uumain(args), - _ => 127, - }; - std::process::exit(code); -} diff --git a/userland/coreutils/tools.txt b/userland/coreutils/tools.txt deleted file mode 100644 index a57aa31..0000000 --- a/userland/coreutils/tools.txt +++ /dev/null @@ -1,78 +0,0 @@ -arch -base32 -base64 -basename -basenc -cat -cksum -b2sum -md5sum -nice -sha1sum -sha224sum -sha256sum -sha384sum -sha512sum -comm -cp -csplit -cut -date -dd -dir -dircolors -dirname -echo -expand -expr -factor -false -fmt -fold -head -hostid -join -link -ln -ls -mkdir -mktemp -mv -nl -numfmt -od -paste -pathchk -pr -printenv -printf -ptx -pwd -readlink -realpath -rm -rmdir -seq -shred -shuf -sleep -sort -split -sum -tail -tee -test -touch -tr -true -truncate -tsort -tty -uname -unexpand -uniq -unlink -vdir -wc -yes -nproc diff --git a/userland/git/Cargo.toml b/userland/git/Cargo.toml index c1a0b78..f5ed58d 100644 --- a/userland/git/Cargo.toml +++ b/userland/git/Cargo.toml @@ -15,3 +15,4 @@ gix-pack = { version = "0.77", default-features = false, features = ["sha1", "ge gix-zlib = "0.4" url = "2" wasi = "0.14" +sausage-ssh = { path = "../ssh-client" } diff --git a/userland/git/README.md b/userland/git/README.md index 85babeb..6a48d9b 100644 --- a/userland/git/README.md +++ b/userland/git/README.md @@ -1,9 +1,18 @@ # Git for WASI 0.2 -This crate adapts the v2 Git plugin's local operations and HTTP transport. +This crate adapts the v2 Git plugin's local operations, HTTP transport, and SSH transport. It uses gix 0.90 and the patches in [`vendor/PATCHES.md`](vendor/PATCHES.md). -It does not use the plugin SDK, RPC dispatch, the vault, or SSH. -The host supplies credentials through standard `wasi:http`. +It does not use the plugin SDK, RPC dispatch, the vault, or native child processes. +The host supplies HTTP credentials and SSH signatures through standard `wasi:http`. + +SSH clone, fetch, and push use the shared [`ssh-client`](../ssh-client/README.md) library in-process. +The remote command runs `git-upload-pack` or `git-receive-pack` over standard WASI sockets. +Remote URLs accept `ssh://user@host[:port]/path` and `user@host:path`. +SCP-style URLs use port 22. +The user can come from the URL or the host identity. +The host key must already match `$HOME/.ssh/known_hosts`. +Use the `ssh` CLI with `-o StrictHostKeyChecking=accept-new` to record a new key. +See [`../ssh/README.md`](../ssh/README.md) for the native sshd fixture and `ssh-test.nu` commands. ## Build @@ -25,7 +34,10 @@ The script checks that the component imports only WASI interfaces. It creates separate native and WASI repositories under `/tmp/p1-git/`. It fixes both identities and timestamps. It compares stdout and exit codes without filtering command output. -Initialization and worktree listings compare exit codes only because their paths differ. +The initialization check normalizes only its repository path. +Worktree listings compare exit codes only because their paths differ. +Mutation checks also compare repository state through native Git on both fixtures. +They compare status, commit IDs, parents, authors, diffs, and references. The script retains its fixtures for inspection. `wasmtime run` needs `-S http`, including for local operations. @@ -34,6 +46,48 @@ The component uses WASI's `exit-with-code`, not a custom host import. The world host must enable that optional WASI function. The tests pass explicit `-C` paths. +## Agent command forms + +The comparison suite covers per-command `-c key=value` overrides and quiet local commands. +It checks multiple revisions, detached status, branch deletion messages, and merge commit output. +It also checks these forms: + +- `status -sb` and `status --porcelain=v1`. +- `log -n`, `--oneline`, `-p`, `--stat`, and one literal path after `--`. +- `log --format` and `--pretty=format:` with `%H %h %s %an %ae %ad %d`. +- `show --stat`, `show HEAD:path`, and merge commits without a combined diff. +- `diff HEAD~1`, `diff A..B`, `--name-only`, `--name-status`, and one literal path after `--`. +- `branch -a`, `-d`, `-D`, `-m`, and `--show-current`. +- `checkout -b`, `switch -c`, and `merge --no-ff`. +- `rev-parse --short HEAD`, `ls-files`, and `config --get`. +- Quiet `init`, `commit`, branch deletion, `checkout`, `switch`, `merge`, and `rebase`. +- `init` in an existing repository without replacement of HEAD or configuration. +- `reset --soft`, `reset --hard`, mixed `reset HEAD~1`, and `reset HEAD `. +- `commit --amend -m`, `commit --amend --no-edit`, and `commit -F`. +- `checkout `, `checkout -- `, `restore `, and `restore --staged`. +- Bare `stash`, `stash push`, `-m`, `list`, `pop`, `apply`, and `drop`. +- Quiet stash push, pop, and drop. +- Lightweight tags, `tag -a -m`, bare `tag`, `--list`, and `-d`. +- `remote -v`, `add`, `remove`, `set-url`, and `get-url`. +- Clean `cherry-pick` and conflict resolution through `--continue` or `--abort`. + +The log applies its path filter before its count limit. +Stashes use ordinary Git commits and the `refs/stash` reflog. +Stash accepts only the listed subcommands. +Cherry-pick conflicts use `CHERRY_PICK_HEAD` and `MERGE_MSG`. + +### Unsupported forms + +These forms return a usage error with exit code 129 and empty stdout. +The suite checks the usage error instead of native Git's successful operation. + +- `log --graph --oneline --all`. +- `show` of a merge that changes the same path relative to every parent, which requires a combined diff. + +The CLI supports textual diffs and literal paths, not Git's complete pathspec language. +The format options support the placeholders above, not Git's complete pretty-format language. +The suite compares stdout and exit codes. It does not compare stderr text. + ## Local smart-HTTP test Build the native fixture server: diff --git a/userland/git/src/changes.rs b/userland/git/src/changes.rs index cfe740f..b91fdfe 100644 --- a/userland/git/src/changes.rs +++ b/userland/git/src/changes.rs @@ -238,6 +238,22 @@ pub fn status(repo: &gix::Repository) -> Result { .map_err(|e| error("status", e))?; for item in iter { let item = item.map_err(|e| error("status", e))?; + #[cfg(target_os = "wasi")] + if matches!( + &item, + Item::Modification { + status: gix::status::plumbing::index_as_worktree::EntryStatus::Change( + gix::status::plumbing::index_as_worktree::Change::Modification { + executable_bit_changed: true, + content_change: None, + .. + } + ), + .. + } + ) { + continue; + } let path = item.rela_path().to_str_lossy().into_owned(); let Some(summary) = item.summary() else { continue; @@ -332,7 +348,7 @@ fn worktree_file( } else { entry.mode }; - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "wasi")))] let mode = if mode != 0o120000 && caps.executable_bit { use std::os::unix::fs::PermissionsExt; if meta.permissions().mode() & 0o111 != 0 { @@ -373,6 +389,18 @@ pub fn diff(p: DiffParams) -> Result> { let repo = repo::open(&p.repo)?; let (old, new, work) = match (&p.from, &p.to, p.staged.unwrap_or(false)) { (Some(a), Some(b), false) => (tree(&repo, a)?, tree(&repo, b)?, false), + (Some(a), None, staged) => { + let (index, _) = index(&repo)?; + if staged { + (tree(&repo, a)?, index, false) + } else { + ( + tree(&repo, a)?, + worktree(&repo, &index, p.path.as_deref())?, + true, + ) + } + } (None, None, staged) => { repo::worktree(&repo)?; let (index, conflicts) = index(&repo)?; diff --git a/userland/git/src/cherry_pick.rs b/userland/git/src/cherry_pick.rs new file mode 100644 index 0000000..78098f5 --- /dev/null +++ b/userland/git/src/cherry_pick.rs @@ -0,0 +1,180 @@ +//! Single-commit cherry-pick uses Git's CHERRY_PICK_HEAD and MERGE_MSG for continuation. +use crate::{Result, branches, checkout, local, output, repo::error, staging}; +use gix::bstr::ByteSlice; + +pub(crate) fn merge_trees( + repo: &gix::Repository, + base: gix::ObjectId, + ours: gix::ObjectId, + theirs: gix::ObjectId, + current: &str, + other: &str, +) -> Result<(gix::ObjectId, gix::index::State, Vec)> { + let local = crate::merge::merge_repository(repo)?; + let labels = gix::merge::blob::builtin_driver::text::Labels { + ancestor: None, + current: Some(current.into()), + other: Some(other.into()), + }; + let mut outcome = local + .merge_trees( + base, + ours, + theirs, + labels, + local + .tree_merge_options() + .map_err(|e| error("merge options", e))?, + ) + .map_err(|e| error("merge trees", e))?; + let tree = outcome + .tree + .write() + .map_err(|e| error("merge tree", e))? + .detach(); + let mut index = gix::index::State::from_tree(&tree, &repo.objects, Default::default()) + .map_err(|e| error("merge index", e))?; + outcome.index_changed_after_applying_conflicts( + &mut index, + Default::default(), + gix::merge::tree::apply_index_entries::RemovalMode::Prune, + ); + index.sort_entries(); + let conflicts = index + .entries() + .iter() + .filter(|e| e.stage_raw() != 0) + .map(|e| e.path(&index).to_str_lossy().into_owned()) + .collect::>() + .into_iter() + .collect(); + Ok((tree, index, conflicts)) +} +fn clear(repo: &gix::Repository) -> Result<()> { + for name in ["CHERRY_PICK_HEAD", "MERGE_MSG"] { + let path = repo.git_dir().join(name); + if path.exists() { + std::fs::remove_file(path).map_err(|e| error("cherry-pick cleanup", e))?; + } + } + Ok(()) +} +pub(crate) fn run( + repo: &gix::Repository, + revision: Option, + resume: bool, + abort: bool, + quiet: bool, +) -> Result { + if abort { + if !repo.git_dir().join("CHERRY_PICK_HEAD").exists() { + return Err(error("cherry-pick abort", "no cherry-pick in progress")); + } + let old = std::fs::read_to_string(repo.git_dir().join("ORIG_HEAD")) + .map_err(|e| error("cherry-pick abort", e))?; + local::reset(repo, old.trim(), &[], false, true, true)?; + clear(repo)?; + return Ok(0); + } + let id = if resume { + let id = std::fs::read_to_string(repo.git_dir().join("CHERRY_PICK_HEAD")) + .map_err(|e| error("cherry-pick continue", e))?; + branches::resolve(repo, id.trim())? + } else { + crate::merge::ensure_idle(repo)?; + crate::merge::clean(repo)?; + branches::resolve(repo, &crate::required(revision, "cherry-pick revision")?)? + }; + let old = branches::resolve(repo, "HEAD")?; + let original = repo + .find_commit(id) + .map_err(|e| error("cherry-pick commit", e))?; + let decoded = original + .decode() + .map_err(|e| error("cherry-pick decode", e))?; + let subject = decoded + .message + .to_str_lossy() + .lines() + .next() + .unwrap_or("") + .to_owned(); + let tree = if resume { + staging::tree_from_index(repo, &staging::load(repo)?)? + } else { + let base = match decoded.parents.first() { + Some(id) => local::tree(repo, &id.to_string())?, + None => repo + .write_object(&gix::objs::Tree { entries: vec![] }) + .map_err(|e| error("empty tree", e))? + .detach(), + }; + let ours = local::tree(repo, "HEAD")?; + let (tree, index, conflicts) = merge_trees( + repo, + base, + ours, + decoded.tree(), + "HEAD", + &format!("{} ({subject})", &id.to_string()[..7]), + )?; + checkout::write_tree_to_worktree(repo, Some(ours), tree, false)?; + staging::publish(repo, index)?; + if !conflicts.is_empty() { + std::fs::write(repo.git_dir().join("CHERRY_PICK_HEAD"), format!("{id}\n")) + .map_err(|e| error("cherry-pick state", e))?; + std::fs::write(repo.git_dir().join("ORIG_HEAD"), format!("{old}\n")) + .map_err(|e| error("cherry-pick state", e))?; + std::fs::write(repo.git_dir().join("MERGE_MSG"), decoded.message.as_bytes()) + .map_err(|e| error("cherry-pick state", e))?; + output::conflicts(&conflicts, false); + return Ok(1); + } + tree + }; + let commit = gix::objs::Commit { + tree, + parents: vec![old].into_iter().collect(), + author: decoded + .author() + .map_err(|e| error("pick author", e))? + .to_owned() + .map_err(|e| error("pick author", e))?, + committer: staging::signature(None, None)?, + encoding: decoded.encoding.map(ToOwned::to_owned), + message: decoded.message.to_owned(), + extra_headers: vec![], + }; + let new = repo + .write_object(&commit) + .map_err(|e| error("cherry-pick write", e))? + .detach(); + staging::update_head( + repo, + Some(old), + new, + &staging::signature(None, None)?, + &format!("cherry-pick: {subject}"), + )?; + clear(repo)?; + if !quiet { + let head = crate::head_info(repo)?; + println!( + "[{} {}] {subject}", + head.branch + .unwrap_or_else(|| "detached HEAD".into()) + .trim_start_matches("refs/heads/"), + &new.to_string()[..7] + ); + output::author_date(&repo.find_commit(new).map_err(|e| error("pick output", e))?)?; + output::commit_stat(&crate::changes::diff(crate::changes::DiffParams { + repo: repo.workdir().unwrap().to_string_lossy().into_owned(), + from: Some(old.to_string()), + to: Some(new.to_string()), + staged: None, + path: None, + context: None, + })?); + } + Ok(0) +} diff --git a/userland/git/src/history.rs b/userland/git/src/history.rs new file mode 100644 index 0000000..c42d582 --- /dev/null +++ b/userland/git/src/history.rs @@ -0,0 +1,77 @@ +//! Path-limited history follows a tree-same parent at merges, as Git's default history simplification does. +use crate::{Result, changes, repo::error}; +use std::collections::BTreeSet; + +pub(crate) fn paths( + repo: &gix::Repository, + start: gix::ObjectId, + path: &str, +) -> Result> { + let empty = repo + .write_object(&gix::objs::Tree { entries: vec![] }) + .map_err(|e| error("log empty tree", e))? + .detach(); + let time = |id| -> Result { + Ok(repo + .find_commit(id) + .map_err(|e| error("log commit", e))? + .committer() + .map_err(|e| error("log committer", e))? + .time() + .map_err(|e| error("log time", e))? + .seconds) + }; + let mut todo = vec![(start, time(start)?)]; + let mut visited = BTreeSet::new(); + let mut visible = Vec::new(); + while !todo.is_empty() { + let next = todo + .iter() + .enumerate() + .min_by_key(|(_, (_, time))| std::cmp::Reverse(*time)) + .unwrap() + .0; + let (id, _) = todo.remove(next); + if !visited.insert(id) { + continue; + } + let commit = repo.find_commit(id).map_err(|e| error("log commit", e))?; + let parents = commit + .parent_ids() + .map(|id| id.detach()) + .collect::>(); + let mut same = None; + for parent in &parents { + if diff(repo, *parent, id, path)?.is_empty() { + same = Some(*parent); + break; + } + } + if let Some(parent) = same { + todo.push((parent, time(parent)?)); + } else { + if !parents.is_empty() || !diff(repo, empty, id, path)?.is_empty() { + visible.push(id); + } + for parent in parents { + todo.push((parent, time(parent)?)); + } + } + } + Ok(visible) +} +fn diff( + repo: &gix::Repository, + from: gix::ObjectId, + to: gix::ObjectId, + path: &str, +) -> Result> { + changes::diff(changes::DiffParams { + repo: repo.workdir().unwrap().to_string_lossy().into_owned(), + from: Some(from.to_string()), + to: Some(to.to_string()), + staged: None, + path: Some(path.into()), + context: None, + }) +} diff --git a/userland/git/src/lib.rs b/userland/git/src/lib.rs index 0a0a8dd..d2cd1f6 100644 --- a/userland/git/src/lib.rs +++ b/userland/git/src/lib.rs @@ -4,6 +4,7 @@ mod branches; mod changes; mod checkout; mod http; +mod ssh; mod merge; mod network; mod output; @@ -55,18 +56,28 @@ fn identity(name: Option, email: Option) -> Result { .or_else(|| config.string("user.email").map(|s| s.to_string())), }) } +mod cherry_pick; +mod history; +mod local; +mod stash; use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(name = "git", disable_help_subcommand = true)] pub struct Cli { #[arg(short = 'C', global = true)] directory: Option, + #[arg(short = 'c', global = true)] + config: Vec, + #[arg(short = 'q', long, global = true)] + quiet: bool, #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { Show { + #[arg(long)] + stat: bool, revision: Option, }, Clone { @@ -96,6 +107,8 @@ enum Command { Config { #[arg(long)] local: bool, + #[arg(long)] + get: bool, key: String, value: Option, }, @@ -106,13 +119,17 @@ enum Command { git_dir: bool, #[arg(long)] abbrev_ref: bool, - revision: Option, + #[arg(long)] + short: bool, + revisions: Vec, }, Status { #[arg(short, long)] short: bool, - #[arg(long)] - porcelain: bool, + #[arg(long, num_args = 0..=1, default_missing_value = "v1", require_equals = true)] + porcelain: Option, + #[arg(short = 'b', long)] + branch: bool, }, Add { #[arg(short = 'A', long)] @@ -126,7 +143,13 @@ enum Command { }, Commit { #[arg(short = 'm')] - message: String, + message: Option, + #[arg(short = 'F', conflicts_with = "message")] + file: Option, + #[arg(long)] + amend: bool, + #[arg(long)] + no_edit: bool, #[arg(short, long)] all: bool, #[arg(long)] @@ -140,19 +163,42 @@ enum Command { oneline: bool, #[arg(short = 'n', default_value = "50")] limit: usize, + #[arg(long)] + format: Option, + #[arg(long)] + pretty: Option, + #[arg(short = 'p')] + patch: bool, + #[arg(long)] + stat: bool, revision: Option, + #[arg(last = true)] + paths: Vec, }, Diff { #[arg(long, alias = "staged")] cached: bool, #[arg(long)] stat: bool, + #[arg(long)] + name_only: bool, + #[arg(long)] + name_status: bool, + revisions: Vec, + #[arg(last = true)] + paths: Vec, }, Branch { + #[arg(long)] + show_current: bool, + #[arg(short = 'a')] + all: bool, #[arg(short = 'd')] delete: bool, #[arg(short = 'D')] force_delete: bool, + #[arg(short = 'm')] + rename: bool, name: Option, start: Option, }, @@ -164,7 +210,8 @@ enum Command { Checkout { #[arg(short = 'b')] create: bool, - target: String, + target: Option, + paths: Vec, }, Merge { #[arg(long)] @@ -192,6 +239,54 @@ enum Command { #[command(subcommand)] command: Worktree, }, + LsFiles, + Reset { + #[arg(long, conflicts_with_all = ["hard", "mixed"])] + soft: bool, + #[arg(long, conflicts_with = "mixed")] + hard: bool, + #[arg(long)] + mixed: bool, + revision: Option, + paths: Vec, + }, + Restore { + #[arg(long)] + staged: bool, + paths: Vec, + }, + Stash { + #[arg(short = 'm', long)] + message: Option, + command: Option, + entry: Option, + }, + Tag { + #[arg(short = 'a')] + annotated: bool, + #[arg(short = 'm')] + message: Option, + #[arg(short = 'l', long)] + list: bool, + #[arg(short = 'd')] + delete: bool, + name: Option, + revision: Option, + }, + Remote { + #[arg(short = 'v', long)] + verbose: bool, + command: Option, + name: Option, + url: Option, + }, + CherryPick { + #[arg(long = "continue")] + resume: bool, + #[arg(long)] + abort: bool, + revision: Option, + }, } #[derive(Subcommand)] enum Worktree { @@ -215,6 +310,24 @@ fn required(value: Option, what: &str) -> Result { value.ok_or_else(|| error(what, "argument required")) } pub fn run(cli: Cli) -> Result { + let quiet = cli.quiet; + let count = std::env::var("GIT_CONFIG_COUNT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + for (i, value) in cli.config.iter().enumerate() { + let (key, value) = value.split_once('=').unwrap_or((value, "true")); + // WASI has one thread. Supply Git's per-command configuration before discovery. + unsafe { + std::env::set_var(format!("GIT_CONFIG_KEY_{}", count + i), key); + std::env::set_var(format!("GIT_CONFIG_VALUE_{}", count + i), value); + } + } + if !cli.config.is_empty() { + unsafe { + std::env::set_var("GIT_CONFIG_COUNT", (count + cli.config.len()).to_string()); + } + } if let Some(dir) = cli.directory { std::env::set_current_dir(dir).map_err(|e| error("chdir", e))?; } @@ -225,24 +338,39 @@ pub fn run(cli: Cli) -> Result { } = cli.command { let path = path.unwrap_or_else(|| ".".into()); - let repo = if bare { - gix::init_bare(&path) + let existing = std::path::Path::new(&path) + .join(if bare { "HEAD" } else { ".git/HEAD" }) + .exists(); + let repo = if existing { + repo::open(&path) } else { - gix::init(&path) + let repo = if bare { + gix::init_bare(&path) + } else { + gix::init(&path) + } + .map_err(|e| error("init", e))?; + std::fs::write( + repo.git_dir().join("HEAD"), + format!("ref: refs/heads/{initial_branch}\n"), + ) + .map_err(|e| error("init HEAD", e))?; + Ok(repo) + }?; + if !quiet { + println!( + "{} Git repository in {}/", + if existing { + "Reinitialized existing" + } else { + "Initialized empty" + }, + repo.git_dir() + .canonicalize() + .map_err(|e| error("init", e))? + .display() + ); } - .map_err(|e| error("init", e))?; - std::fs::write( - repo.git_dir().join("HEAD"), - format!("ref: refs/heads/{initial_branch}\n"), - ) - .map_err(|e| error("init HEAD", e))?; - println!( - "Initialized empty Git repository in {}/", - repo.git_dir() - .canonicalize() - .map_err(|e| error("init", e))? - .display() - ); return Ok(0); } if let Command::Clone { url, path } = cli.command { @@ -266,6 +394,101 @@ pub fn run(cli: Cli) -> Result { let path = root.to_string_lossy().into_owned(); let repository = repo::open(&path)?; match cli.command { + Command::Reset { + soft, + hard, + revision, + paths, + .. + } => local::reset( + &repository, + revision.as_deref().unwrap_or("HEAD"), + &paths, + soft, + hard, + quiet, + )?, + Command::Restore { staged, paths } => local::restore( + &repository, + if staged { Some("HEAD") } else { None }, + &paths, + staged, + )?, + Command::Stash { + command, + message, + entry, + } => { + return stash::run( + &repository, + command.as_deref().unwrap_or("push"), + message, + entry, + quiet, + ); + } + Command::Tag { + annotated, + message, + delete, + name, + revision, + .. + } => local::tag(&repository, name, revision, annotated, message, delete)?, + Command::Remote { + verbose, + command, + name, + url, + } => match command.as_deref() { + Some("add") | Some("set-url") => { + remotes::set( + &repository, + &required(name, "remote name")?, + &required(url, "remote URL")?, + command.as_deref() == Some("add"), + )?; + } + Some("remove") => remotes::remove(&repository, &required(name, "remote name")?)?, + Some("get-url") => { + let name = required(name, "remote name")?; + let remote = remotes::list(&repository)? + .into_iter() + .find(|r| r.name == name) + .ok_or_else(|| error("remote", "remote does not exist"))?; + println!("{}", remote.url.unwrap_or_default()); + } + None => { + for remote in remotes::list(&repository)? { + if verbose { + let url = remote.url.unwrap_or_default(); + println!( + "{}\t{} (fetch)\n{}\t{} (push)", + remote.name, url, remote.name, url + ); + } else { + println!("{}", remote.name); + } + } + } + _ => { + eprintln!("usage: git remote [add|remove|set-url|get-url]"); + return Ok(129); + } + }, + Command::CherryPick { + resume, + abort, + revision, + } => return cherry_pick::run(&repository, revision, resume, abort, quiet), + Command::LsFiles => { + let index = repository + .index_or_empty() + .map_err(|e| error("ls-files", e))?; + for entry in index.entries() { + println!("{}", entry.path(&index)); + } + } Command::Config { key, value, .. } => { let file = repository.common_dir().join("config"); if let Some(value) = value { @@ -291,7 +514,8 @@ pub fn run(cli: Cli) -> Result { show_toplevel, git_dir, abbrev_ref, - revision, + short, + revisions, } => { if show_toplevel { println!("{}", root.display()); @@ -306,12 +530,13 @@ pub fn run(cli: Cli) -> Result { .trim_start_matches("refs/heads/") ); } else { - println!( - "{}", - repository - .rev_parse_single(required(revision, "revision")?.as_str()) + for revision in revisions { + let id = repository + .rev_parse_single(revision.as_str()) .map_err(|e| error("revision", e))? - ); + .to_string(); + println!("{}", if short { &id[..7] } else { &id }); + } } } Command::Add { all, mut paths } => { @@ -341,12 +566,33 @@ pub fn run(cli: Cli) -> Result { } Command::Commit { message, + file, + amend, + no_edit, all, allow_empty, .. } => { let before = head_info(&repository)?; - if !allow_empty && merge::pending_parent(&repository)?.is_none() { + let message = if let Some(file) = file { + std::fs::read_to_string(file).map_err(|e| error("commit message", e))? + } else if let Some(message) = message { + message + } else if amend && no_edit { + repository + .head_commit() + .map_err(|e| error("amend", e))? + .decode() + .map_err(|e| error("amend message", e))? + .message + .to_str_lossy() + .into_owned() + } else { + eprintln!("usage: git commit -m | -F | --amend --no-edit"); + return Ok(129); + }; + let message = message.trim_end().to_owned(); + if !amend && !allow_empty && merge::pending_parent(&repository)?.is_none() { let status = changes::status(&repository)?; if status.staged.is_empty() && (!all || status.unstaged.is_empty()) @@ -364,10 +610,13 @@ pub fn run(cli: Cli) -> Result { author_name: None, author_email: None, all: Some(all), - amend: None, + amend: Some(amend), allow_empty: Some(allow_empty), }, )?; + if quiet { + return Ok(0); + } println!( "[{}{} {}] {}", before @@ -376,9 +625,31 @@ pub fn run(cli: Cli) -> Result { .trim_start_matches("refs/heads/"), if before.unborn { " (root-commit)" } else { "" }, &result.id[..7], - message + message.lines().next().unwrap_or("") ); - let old = match before.id { + if amend { + output::author_date( + &repository + .find_commit( + gix::ObjectId::from_hex(result.id.as_bytes()) + .map_err(|e| error("amend", e))?, + ) + .map_err(|e| error("amend", e))?, + )?; + } + let old = match if amend { + repository + .find_commit( + gix::ObjectId::from_hex(result.id.as_bytes()) + .map_err(|e| error("amend", e))?, + ) + .map_err(|e| error("amend", e))? + .parent_ids() + .next() + .map(|id| id.to_string()) + } else { + before.id + } { Some(id) => id, None => repository .write_object(&gix::objs::Tree { entries: vec![] }) @@ -394,7 +665,11 @@ pub fn run(cli: Cli) -> Result { context: None, })?); } - Command::Status { short, porcelain } => { + Command::Status { + short, + porcelain, + branch, + } => { let mut status = changes::status(&repository)?; let index = repository .index_or_empty() @@ -414,10 +689,30 @@ pub fn run(cli: Cli) -> Result { untracked.insert(collapsed.to_owned()); } status.untracked = untracked.into_iter().collect(); - if !short && !porcelain { + if !short && porcelain.is_none() { output::long_status(&status); return Ok(0); } + if branch { + println!( + "## {}", + if status.head.detached { + "HEAD (no branch)".into() + } else { + let name = status + .head + .branch + .as_deref() + .unwrap_or("HEAD") + .trim_start_matches("refs/heads/"); + if status.head.unborn { + format!("No commits yet on {name}") + } else { + name.to_owned() + } + } + ); + } let mut rows = std::collections::BTreeMap::new(); for c in status.staged { rows.entry(c.path).or_insert([' '; 2])[0] = code(&c.status); @@ -431,20 +726,45 @@ pub fn run(cli: Cli) -> Result { for p in status.conflicted { rows.insert(p, ['U', 'U']); } - for (p, c) in rows { - println!("{}{} {p}", c[0], c[1]); + for untracked in [false, true] { + for (p, c) in &rows { + if (c == &['?', '?']) == untracked { + println!("{}{} {p}", c[0], c[1]); + } + } } } - Command::Diff { cached, stat } => { + Command::Diff { + cached, + stat, + name_only, + name_status, + revisions, + paths, + } => { + let (from, to) = if revisions.len() == 1 && revisions[0].contains("..") { + let (a, b) = revisions[0].split_once("..").unwrap(); + (Some(a.into()), Some(b.into())) + } else { + (revisions.first().cloned(), revisions.get(1).cloned()) + }; let diffs = changes::diff(changes::DiffParams { repo: path, - from: None, - to: None, + from, + to, staged: Some(cached), - path: None, + path: paths.first().cloned(), context: None, })?; - if stat { + if name_only || name_status { + for diff in diffs { + if name_status { + println!("{}\t{}", code(&diff.status), diff.path); + } else { + println!("{}", diff.path); + } + } + } else if stat { output::stat(&diffs, false); } else { for diff in diffs { @@ -455,7 +775,12 @@ pub fn run(cli: Cli) -> Result { Command::Log { oneline, limit, + format, + pretty, + patch, + stat, revision, + paths, } => { let commit = repository .rev_parse_single(revision.as_deref().unwrap_or("HEAD")) @@ -464,24 +789,102 @@ pub fn run(cli: Cli) -> Result { .map_err(|e| error("log", e))? .peel_to_commit() .map_err(|e| error("log", e))?; - let mut first = true; - for info in commit - .ancestors() - .use_commit_graph(false) - .sorting(gix::revision::walk::Sorting::ByCommitTime( - Default::default(), - )) - .all() - .map_err(|e| error("log", e))? - .take(limit) - { - let c = info + let ids = if let Some(path) = paths.first() { + history::paths(&repository, commit.id, path)? + } else { + commit + .ancestors() + .use_commit_graph(false) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + Default::default(), + )) + .all() .map_err(|e| error("log", e))? - .object() - .map_err(|e| error("log", e))?; + .take(limit) + .map(|info| info.map(|info| info.id).map_err(|e| error("log", e))) + .collect::>>()? + }; + let mut first = true; + for id in ids.into_iter().take(limit) { + let c = repository.find_commit(id).map_err(|e| error("log", e))?; let decoded = c.decode().map_err(|e| error("log", e))?; let message = decoded.message.to_str_lossy(); - if oneline { + let diffs = if patch || stat || !paths.is_empty() { + let old = match decoded.parents.first() { + Some(id) => id.to_string(), + None => repository + .write_object(&gix::objs::Tree { entries: vec![] }) + .map_err(|e| error("log tree", e))? + .to_string(), + }; + changes::diff(changes::DiffParams { + repo: path.clone(), + from: Some(old), + to: Some(c.id.to_string()), + staged: None, + path: paths.first().cloned(), + context: None, + })? + } else { + Vec::new() + }; + if let Some(template) = format.as_deref().or(pretty + .as_deref() + .map(|s| s.strip_prefix("format:").unwrap_or(s))) + { + let author = c.author().map_err(|e| error("log author", e))?; + let date = author + .time() + .map_err(|e| error("log date", e))? + .format_or_unix(gix::date::time::format::DEFAULT); + let mut labels = Vec::new(); + for reference in repository + .references() + .map_err(|e| error("log refs", e))? + .all() + .map_err(|e| error("log refs", e))? + { + let reference = reference.map_err(|e| error("log refs", e))?; + let name = reference.name().as_bstr().to_str_lossy(); + if repository + .rev_parse_single(name.as_ref()) + .map(|id| id.to_string() == c.id.to_string()) + .unwrap_or(false) + { + let short = reference.name().shorten().to_string(); + labels.push( + if head_info(&repository)?.branch.as_deref() == Some(name.as_ref()) + { + format!("HEAD -> {short}") + } else { + short + }, + ); + } + } + let decorations = if labels.is_empty() { + String::new() + } else { + format!(" ({})", labels.join(", ")) + }; + let text = template + .replace("%H", &c.id.to_string()) + .replace("%h", &c.id.to_string()[..7]) + .replace("%s", message.lines().next().unwrap_or("")) + .replace("%an", &author.name.to_string()) + .replace("%ae", &author.email.to_string()) + .replace("%ad", &date) + .replace("%d", &decorations); + if pretty.is_some() { + if !first { + println!(); + } + print!("{text}"); + } else { + println!("{text}"); + } + first = false; + } else if oneline { println!( "{} {}", &c.id.to_string()[..7], @@ -494,24 +897,61 @@ pub fn run(cli: Cli) -> Result { output::commit(&c)?; first = false; } + if patch || stat { + println!(); + if stat { + output::stat(&diffs, false); + } + if patch { + for diff in diffs { + print!("{}", diff.patch); + } + } + } } } Command::Branch { delete, force_delete, + show_current, + all, + rename, name, start, } => { + if rename { + let new = required(start.clone().or(name.clone()), "new branch name")?; + local::rename_branch( + &repository, + if start.is_some() { + name.as_deref() + } else { + None + }, + &new, + )?; + return Ok(0); + } + if show_current { + if let Some(branch) = head_info(&repository)?.branch { + println!("{}", branch.trim_start_matches("refs/heads/")); + } + return Ok(0); + } if let Some(name) = name { if delete || force_delete { + let id = branches::resolve(&repository, &name)?.to_string(); branches::delete( &repository, branches::BranchDeleteParams { repo: path, - name, + name: name.clone(), force: Some(force_delete), }, )?; + if !quiet { + println!("Deleted branch {name} (was {}).", &id[..7]); + } } else { branches::create( &repository, @@ -528,10 +968,16 @@ pub fn run(cli: Cli) -> Result { for branch in repository .references() .map_err(|e| error("branch", e))? - .local_branches() + .all() .map_err(|e| error("branch", e))? { let branch = branch.map_err(|e| error("branch", e))?; + let name = branch.name().as_bstr().to_str_lossy(); + if !name.starts_with("refs/heads/") + && !(all && name.starts_with("refs/remotes/")) + { + continue; + } println!( "{} {}", if head.branch.as_deref() @@ -541,12 +987,48 @@ pub fn run(cli: Cli) -> Result { } else { " " }, - branch.name().shorten() + if name.starts_with("refs/remotes/") { + format!("remotes/{}", name.trim_start_matches("refs/remotes/")) + } else { + branch.name().shorten().to_string() + } ); } } } - Command::Switch { create, target } | Command::Checkout { create, target } => { + Command::Checkout { + create, + target, + mut paths, + } => { + if !create + && (!paths.is_empty() + || target.as_ref().is_some_and(|t| { + root.join(t).is_file() + && repository + .try_find_reference(format!("refs/heads/{t}").as_str()) + .ok() + .flatten() + .is_none() + })) + { + if let Some(target) = target { + paths.insert(0, target); + } + local::restore(&repository, None, &paths, false)?; + } else { + branches::checkout( + &repository, + branches::CheckoutParams { + repo: path, + target: required(target, "checkout target")?, + create: Some(create), + force: None, + }, + )?; + } + } + Command::Switch { create, target } => { branches::checkout( &repository, branches::CheckoutParams { @@ -588,10 +1070,13 @@ pub fn run(cli: Cli) -> Result { }, )? }; - if r.already_up_to_date { + if r.already_up_to_date && !quiet { println!("Already up to date."); } output::conflicts(&r.conflicts, true); + if quiet { + return Ok(if r.conflicts.is_empty() { 0 } else { 1 }); + } if !r.conflicts.is_empty() { return Ok(1); } @@ -637,6 +1122,10 @@ pub fn run(cli: Cli) -> Result { }, )? }; + if quiet { + output::conflicts(&r.conflicts, false); + return Ok(if r.conflicts.is_empty() { 0 } else { 1 }); + } if let Some(id) = &r.resolved { let commit = repository .find_commit( @@ -766,7 +1255,7 @@ pub fn run(cli: Cli) -> Result { )?; } }, - Command::Show { revision } => { + Command::Show { revision, stat } => { let object = repository .rev_parse_single(revision.as_deref().unwrap_or("HEAD")) .map_err(|e| error("show", e))? @@ -779,8 +1268,38 @@ pub fn run(cli: Cli) -> Result { .map_err(|e| error("show", e))?; } else { let commit = object.peel_to_commit().map_err(|e| error("show", e))?; + let merge_commit = commit.parent_ids().count() > 1; + if merge_commit && !stat { + let mut common: Option> = None; + for parent in commit.parent_ids() { + let names = changes::diff(changes::DiffParams { + repo: path.clone(), + from: Some(parent.to_string()), + to: Some(commit.id.to_string()), + staged: None, + path: None, + context: None, + })? + .into_iter() + .map(|d| d.path) + .collect::>(); + common = Some(match common { + None => names, + Some(paths) => paths.intersection(&names).cloned().collect(), + }); + } + if common.is_some_and(|paths| !paths.is_empty()) { + eprintln!( + "usage: git show: combined merge diffs are not supported (see README.md)" + ); + return Ok(129); + } + } output::commit(&commit)?; println!(); + if merge_commit && !stat { + return Ok(0); + } let old = match commit.parent_ids().next() { Some(id) => id.to_string(), None => repository @@ -788,15 +1307,20 @@ pub fn run(cli: Cli) -> Result { .map_err(|e| error("show", e))? .to_string(), }; - for diff in changes::diff(changes::DiffParams { + let diffs = changes::diff(changes::DiffParams { repo: path, from: Some(old), to: Some(commit.id.to_string()), staged: None, path: None, context: None, - })? { - print!("{}", diff.patch); + })?; + if stat { + output::stat(&diffs, false); + } else { + for diff in diffs { + print!("{}", diff.patch); + } } } } diff --git a/userland/git/src/local.rs b/userland/git/src/local.rs new file mode 100644 index 0000000..7cb205d --- /dev/null +++ b/userland/git/src/local.rs @@ -0,0 +1,283 @@ +//! Local reset, path restoration, tags, remotes, and branch renames use ordinary Git metadata. +use crate::{Result, branches, changes, checkout, head_info, remotes, repo::error, staging}; +use gix::bstr::ByteSlice; +use gix::index::State; +#[cfg(unix)] +use gix::index::entry::Mode; +use gix::refs::{ + Target, + transaction::{Change, PreviousValue, RefEdit}, +}; + +pub(crate) fn tree(repo: &gix::Repository, revision: &str) -> Result { + Ok(repo + .rev_parse_single(revision) + .map_err(|e| error("revision", e))? + .object() + .map_err(|e| error("revision", e))? + .peel_to_tree() + .map_err(|e| error("tree", e))? + .id) +} +fn selected(path: &str, paths: &[String]) -> bool { + paths + .iter() + .any(|p| p == "." || path == p || path.strip_prefix(p).is_some_and(|s| s.starts_with('/'))) +} +pub(crate) fn restore( + repo: &gix::Repository, + revision: Option<&str>, + paths: &[String], + staged: bool, +) -> Result<()> { + let source = match revision { + Some(revision) => { + State::from_tree(&tree(repo, revision)?, &repo.objects, Default::default()) + .map_err(|e| error("restore tree", e))? + } + None => staging::load(repo)?, + }; + let mut state = staging::load(repo)?; + if staged { + state.remove_entries(|_, p, _| selected(p.to_str_lossy().as_ref(), paths)); + for e in source + .entries() + .iter() + .filter(|e| selected(e.path(&source).to_str_lossy().as_ref(), paths)) + { + state.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, e.path(&source)); + } + staging::publish(repo, state)?; + } else { + let root = crate::repo::worktree(repo)?; + for path in paths { + if path != "." { + crate::repo::validate_path(path)?; + } + } + for e in source + .entries() + .iter() + .filter(|e| selected(e.path(&source).to_str_lossy().as_ref(), paths)) + { + if e.stage_raw() != 0 { + return Err(error("restore", "unmerged path")); + } + staging::safe(root, e.path(&source).to_str_lossy().as_ref())?; + let path = root.join(e.path(&source).to_str_lossy().as_ref()); + let bytes = repo + .find_blob(e.id) + .map_err(|e| error("restore blob", e))? + .data + .to_vec(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| error("restore directory", e))?; + } + std::fs::write(&path, bytes).map_err(|e| error("restore file", e))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &path, + std::fs::Permissions::from_mode(if e.mode == Mode::FILE_EXECUTABLE { + 0o755 + } else { + 0o644 + }), + ) + .map_err(|e| error("restore mode", e))?; + } + } + } + Ok(()) +} +pub(crate) fn reset( + repo: &gix::Repository, + revision: &str, + paths: &[String], + soft: bool, + hard: bool, + quiet: bool, +) -> Result<()> { + if !paths.is_empty() { + restore(repo, Some(revision), paths, true)?; + } else { + let id = branches::resolve(repo, revision)?; + let old = head_info(repo)?.id; + if hard { + checkout::write_tree_to_worktree(repo, None, tree(repo, revision)?, true)?; + } else if !soft { + let state = State::from_tree(&tree(repo, revision)?, &repo.objects, Default::default()) + .map_err(|e| error("reset index", e))?; + staging::publish(repo, state)?; + } + if let Some(old) = old { + std::fs::write(repo.git_dir().join("ORIG_HEAD"), format!("{old}\n")) + .map_err(|e| error("reset ORIG_HEAD", e))?; + staging::update_head( + repo, + Some(gix::ObjectId::from_hex(old.as_bytes()).map_err(|e| error("reset HEAD", e))?), + id, + &staging::signature(None, None)?, + "reset", + )?; + } + if hard && !quiet { + let commit = repo.find_commit(id).map_err(|e| error("reset commit", e))?; + println!( + "HEAD is now at {} {}", + &id.to_string()[..7], + commit + .decode() + .map_err(|e| error("reset message", e))? + .message + .to_str_lossy() + .lines() + .next() + .unwrap_or("") + ); + } + } + if !soft && !hard && !quiet { + let status = changes::status(&crate::repo::open( + &repo.workdir().unwrap().to_string_lossy(), + )?)?; + if !status.unstaged.is_empty() { + println!("Unstaged changes after reset:"); + for c in status.unstaged { + println!("{}\t{}", crate::code(&c.status), c.path); + } + } + } + Ok(()) +} +pub(crate) fn rename_branch(repo: &gix::Repository, old: Option<&str>, new: &str) -> Result<()> { + let head = head_info(repo)?; + let old = branches::branch_name( + old.unwrap_or( + head.branch + .as_deref() + .unwrap_or("HEAD") + .trim_start_matches("refs/heads/"), + ), + )?; + let new = branches::branch_name(new)?; + crate::worktrees::ensure_available(repo, &old)?; + let mut edits = vec![ + RefEdit { + name: new + .as_str() + .try_into() + .map_err(|e| error("branch name", e))?, + deref: false, + change: Change::Update { + expected: PreviousValue::MustNotExist, + new: Target::Object(branches::resolve(repo, &old)?), + log: Default::default(), + }, + }, + RefEdit { + name: old + .as_str() + .try_into() + .map_err(|e| error("branch name", e))?, + deref: false, + change: Change::Delete { + expected: PreviousValue::MustExist, + log: gix::refs::transaction::RefLog::AndReference, + }, + }, + ]; + if head.branch.as_deref() == Some(old.as_str()) { + edits.push(RefEdit { + name: "HEAD".try_into().unwrap(), + deref: false, + change: Change::Update { + expected: PreviousValue::Any, + new: Target::Symbolic(new.as_str().try_into().unwrap()), + log: Default::default(), + }, + }); + } + branches::edit_refs(repo, edits)?; + let mut config = remotes::load(repo)?; + let old_short = old.trim_start_matches("refs/heads/"); + if config.section("branch", old_short).is_ok() { + config + .rename_section( + "branch", + old_short, + "branch", + new.trim_start_matches("refs/heads/"), + ) + .map_err(|e| error("branch config", e))?; + remotes::save(repo, &config)?; + } + Ok(()) +} +pub(crate) fn tag( + repo: &gix::Repository, + name: Option, + revision: Option, + annotated: bool, + message: Option, + delete: bool, +) -> Result<()> { + let Some(name) = name else { + for reference in repo + .references() + .map_err(|e| error("tags", e))? + .all() + .map_err(|e| error("tags", e))? + { + let reference = reference.map_err(|e| error("tag", e))?; + if reference.name().as_bstr().starts_with(b"refs/tags/") { + println!("{}", reference.name().shorten()); + } + } + return Ok(()); + }; + let full = format!("refs/tags/{name}"); + if delete { + let reference = repo + .find_reference(full.as_str()) + .map_err(|e| error("tag", e))?; + let id = reference + .target() + .try_id() + .ok_or_else(|| error("tag", "symbolic tag"))? + .to_string(); + reference.delete().map_err(|e| error("tag delete", e))?; + println!("Deleted tag '{name}' (was {})", &id[..7]); + } else { + let id = branches::resolve(repo, revision.as_deref().unwrap_or("HEAD"))?; + let id = if annotated || message.is_some() { + let object = gix::objs::Tag { + target: id, + target_kind: gix::objs::Kind::Commit, + name: name.clone().into(), + tagger: Some(staging::signature(None, None)?), + message: format!("{}\n", message.unwrap_or_default().trim_end()).into(), + signature: None, + }; + repo.write_object(&object) + .map_err(|e| error("tag object", e))? + .detach() + } else { + id + }; + branches::edit_refs( + repo, + vec![RefEdit { + name: full.as_str().try_into().map_err(|e| error("tag name", e))?, + deref: false, + change: Change::Update { + expected: PreviousValue::MustNotExist, + new: Target::Object(id), + log: Default::default(), + }, + }], + )?; + } + Ok(()) +} diff --git a/userland/git/src/network.rs b/userland/git/src/network.rs index b88c41c..bbdb2d2 100644 --- a/userland/git/src/network.rs +++ b/userland/git/src/network.rs @@ -1,9 +1,10 @@ -//! Anonymous HTTP and HTTPS clone/fetch through standard wasi:http. +//! Clone and fetch over standard WASI HTTP or in-process SSH exec channels. use crate::{ Result, - http::{WasiHttp, clean_url}, + http::WasiHttp, remotes, repo::{self, error}, + ssh::clean_url, }; use gix::bstr::ByteSlice; use std::{collections::BTreeSet, sync::atomic::AtomicBool}; @@ -82,18 +83,24 @@ fn perform( )); } } - let transport: Box = { - let transport_url = gix::url::parse(url.as_bytes().as_bstr()) - .map_err(|_| error("fetch", "invalid HTTPS URL"))?; - Box::new( - gix_transport::client::blocking_io::http::Transport::new_http( - WasiHttp::default(), - transport_url, - gix_transport::Protocol::V2, - false, - ), - ) - }; + let transport: Box = + if crate::ssh::is_ssh(&url) { + Box::new(crate::ssh::connection( + &url, + gix_transport::Service::UploadPack, + )?) + } else { + let transport_url = gix::url::parse(url.as_bytes().as_bstr()) + .map_err(|_| error("fetch", "invalid HTTPS URL"))?; + Box::new( + gix_transport::client::blocking_io::http::Transport::new_http( + WasiHttp::default(), + transport_url, + gix_transport::Protocol::V2, + false, + ), + ) + }; let mut options = gix::remote::ref_map::Options::default(); options.extra_refspecs.push( gix::refspec::parse("HEAD".into(), gix::refspec::parse::Operation::Fetch) diff --git a/userland/git/src/output.rs b/userland/git/src/output.rs index 5586bd7..5e892c3 100644 --- a/userland/git/src/output.rs +++ b/userland/git/src/output.rs @@ -36,6 +36,19 @@ pub(crate) fn commit(commit: &gix::Commit<'_>) -> crate::Result<()> { ); Ok(()) } +pub(crate) fn author_date(commit: &gix::Commit<'_>) -> crate::Result<()> { + let author = commit + .author() + .map_err(|e| crate::repo::error("author", e))?; + println!( + " Date: {}", + author + .time() + .map_err(|e| crate::repo::error("date", e))? + .format_or_unix(gix::date::time::format::DEFAULT) + ); + Ok(()) +} pub(crate) fn long_status(s: &Status) { let branch = s .head @@ -43,7 +56,14 @@ pub(crate) fn long_status(s: &Status) { .as_deref() .unwrap_or("HEAD") .trim_start_matches("refs/heads/"); - println!("On branch {branch}"); + if s.head.detached { + println!( + "HEAD detached at {}", + &s.head.id.as_deref().unwrap_or("HEAD")[..7] + ); + } else { + println!("On branch {branch}"); + } if s.head.unborn { println!("\nNo commits yet\n"); } diff --git a/userland/git/src/push.rs b/userland/git/src/push.rs index 31bde06..5e3b191 100644 --- a/userland/git/src/push.rs +++ b/userland/git/src/push.rs @@ -1,4 +1,4 @@ -//! Single-ref receive-pack client over standard wasi:http. +//! Single-ref receive-pack over standard WASI HTTP or in-process SSH exec channels. use crate::Result; use crate::{ @@ -587,6 +587,9 @@ pub fn push(p: PushParams) -> Result { .to_bstring() .to_str_lossy() .into_owned(); + if crate::ssh::is_ssh(&url) { + return ssh_push(&repo, &url, remote, &plan, p.set_upstream.unwrap_or(false)); + } let base = clean_url(&url) .map_err(|e| error("push URL", e))? .to_string() @@ -670,3 +673,57 @@ pub fn push(p: PushParams) -> Result { })(); result } +fn ssh_push( + repo: &gix::Repository, + url: &str, + remote: &str, + plan: &Plan, + upstream: bool, +) -> Result { + let (mut read, mut write) = + crate::ssh::connection(url, gix_transport::Service::ReceivePack)?.into_inner(); + let advertised = advertise_refs(&mut read)?; + let old = advertised.refs.get(&plan.destination).copied(); + if let (Some(old), Some(new)) = (old, plan.new) { + if old != new && !plan.force && !is_ancestor(repo, old, new)? { + return Err(error("push", "non-fast-forward; fetch first or use force")); + } + } + let up_to_date = old == plan.new; + let mut objects = 0; + if !up_to_date { + if plan.new.is_none() && !advertised.caps.contains("delete-refs") { + return Err(error("push", "remote does not allow deleting refs")); + } + let ids = match plan.new { + Some(new) => objects_to_send(repo, new, advertised.refs.values().copied())?, + None => BTreeSet::new(), + }; + objects = ids.len(); + let sideband = advertised.caps.contains("side-band-64k"); + let mut caps = vec!["report-status"]; + if sideband { + caps.push("side-band-64k"); + } + commands(old, plan, &caps, &mut write)?; + if plan.new.is_some() { + pack(repo, &ids, &mut write)?; + } + write.flush().map_err(|e| error("push SSH flush", e))?; + report(&mut read, &plan.destination, sideband)?; + } else { + write + .write_all(b"0000") + .and_then(|_| write.flush()) + .map_err(|e| error("push SSH finish", e))?; + } + update_local(repo, remote, plan, upstream)?; + Ok(PushResult { + remote: remote.into(), + reference: plan.destination.clone(), + old: old.map(|id| id.to_string()), + new: plan.new.map(|id| id.to_string()), + up_to_date, + objects, + }) +} diff --git a/userland/git/src/remotes.rs b/userland/git/src/remotes.rs index ac32790..b8d61c5 100644 --- a/userland/git/src/remotes.rs +++ b/userland/git/src/remotes.rs @@ -1,6 +1,6 @@ //! Non-destructive local Git config edits. No plugin-owned persistent format. use crate::Result; -use crate::{http::clean_url, repo::error}; +use crate::{repo::error, ssh::clean_url}; use gix::bstr::ByteSlice; /// Remote identity. @@ -81,7 +81,7 @@ pub fn list(repo: &gix::Repository) -> Result> { // Do not return legacy embedded credentials from repositories imported outside this plugin. let url = url .map(|s| { - clean_url(&s).map(|u| u.to_string()).map_err(|_| { + clean_url(&s).map_err(|_| { error( "remote list", "remote URL must be HTTPS without credentials or SSH without password", @@ -116,7 +116,7 @@ pub fn set(repo: &gix::Repository, name: &str, url: &str, add: bool) -> Result = config .sections_and_ids_by_name("remote") @@ -131,17 +131,19 @@ pub fn set(repo: &gix::Repository, name: &str, url: &str, add: bool) -> Result io::Result { + let url = gix::url::parse(input.as_bytes().as_bstr()) + .map_err(|_| io::Error::other("invalid SSH URL"))?; + if url.scheme != gix::url::Scheme::Ssh + || url.host.as_deref().is_none_or(str::is_empty) + || url.password.is_some() + || url.path.is_empty() + || url.path.iter().any(|b| matches!(b, 0 | b'\n' | b'\r')) + { + return Err(io::Error::other( + "use ssh://[user@]host[:port]/path or [user@]host:path without a password", + )); + } + Ok(url) +} +pub fn clean_url(input: &str) -> io::Result { + if input.starts_with("http://") || input.starts_with("https://") { + crate::http::clean_url(input).map(|url| url.to_string()) + } else { + parse(input)?; + Ok(input.into()) + } +} +pub fn is_ssh(input: &str) -> bool { + !input.starts_with("http://") && !input.starts_with("https://") +} +pub type Connection = + gix_transport::client::git::blocking_io::Connection; +pub fn connection(input: &str, service: gix_transport::Service) -> Result { + let url = parse(input).map_err(|e| error("SSH URL", e))?; + let host = url.host.as_deref().unwrap(); + let mut session = + sausage_ssh::identity::connect(host, url.port.unwrap_or(22), url.user.as_deref(), false) + .map_err(|e| error("SSH", e))?; + let mut command = service.as_str().as_bytes().to_vec(); + command.extend_from_slice(b" '"); + for byte in url.path.iter() { + if *byte == b'\'' { + command.extend_from_slice(b"'\\''"); + } else { + command.push(*byte); + } + } + command.push(b'\''); + session + .open_command(&command) + .map_err(|e| error("SSH exec", e))?; + let (read, write) = session.split(); + Ok(Connection::new( + read, + write, + gix_transport::Protocol::V1, + url.path, + None::<(&str, Option)>, + gix_transport::client::git::ConnectMode::Process, + false, + ) + .custom_url(Some(input.into()))) +} diff --git a/userland/git/src/staging.rs b/userland/git/src/staging.rs index a6b9f23..a0e59a3 100644 --- a/userland/git/src/staging.rs +++ b/userland/git/src/staging.rs @@ -116,7 +116,7 @@ fn meta(path: &Path) -> Result> { } } // Do not traverse symlink parents even when an explicit path points outside via a link. -fn safe(root: &Path, path: &str) -> Result<()> { +pub(crate) fn safe(root: &Path, path: &str) -> Result<()> { validate(path)?; for parent in Path::new(path) .ancestors() @@ -129,7 +129,7 @@ fn safe(root: &Path, path: &str) -> Result<()> { } Ok(()) } -fn load(repo: &gix::Repository) -> Result { +pub(crate) fn load(repo: &gix::Repository) -> Result { repo::worktree(repo)?; let index = repo.index_or_empty().map_err(|e| error("stage index", e))?; let mut state = State::new(repo.object_hash()); @@ -229,7 +229,7 @@ fn work_entry( Mode::SYMLINK, ) } else if m.is_file() { - #[cfg(unix)] + #[cfg(all(unix, not(target_os = "wasi")))] let mode = { use std::os::unix::fs::PermissionsExt; if m.permissions().mode() & 0o111 != 0 { @@ -238,7 +238,7 @@ fn work_entry( Mode::FILE } }; - #[cfg(not(unix))] + #[cfg(any(not(unix), target_os = "wasi"))] let mode = if previous == Some(Mode::FILE_EXECUTABLE) { Mode::FILE_EXECUTABLE } else { @@ -324,6 +324,16 @@ fn stage(repo: &gix::Repository, state: State, selected: BTreeSet) -> Re state.sort_entries(); Ok(state) } +pub(crate) fn tracked_tree(repo: &gix::Repository) -> Result { + let state = load(repo)?; + let selected = state + .entries() + .iter() + .filter(|e| e.mode != Mode::COMMIT) + .map(|e| e.path(&state).to_str_lossy().into_owned()) + .collect(); + tree_from_index(repo, &stage(repo, state, selected)?) +} pub(crate) fn add(repo: &gix::Repository, p: AddParams) -> Result { if !p.all.unwrap_or(false) { validate_paths(&p.paths)?; diff --git a/userland/git/src/stash.rs b/userland/git/src/stash.rs new file mode 100644 index 0000000..8ea2e6d --- /dev/null +++ b/userland/git/src/stash.rs @@ -0,0 +1,227 @@ +//! Stashes are standard two-parent Git commits with a refs/stash reflog. +use crate::{ + Result, branches, changes, checkout, cherry_pick, local, output, repo::error, staging, +}; +use gix::{ + bstr::ByteSlice, + refs::{ + Target, + transaction::{Change, LogChange, PreviousValue, RefEdit}, + }, +}; +fn log_path(repo: &gix::Repository) -> std::path::PathBuf { + repo.common_dir().join("logs/refs/stash") +} +fn logs(repo: &gix::Repository) -> Result> { + match std::fs::read_to_string(log_path(repo)) { + Ok(text) => Ok(text.lines().map(str::to_owned).collect()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]), + Err(e) => Err(error("stash log", e)), + } +} +fn commit( + repo: &gix::Repository, + tree: gix::ObjectId, + parents: Vec, + message: &str, +) -> Result { + let sig = staging::signature(None, None)?; + repo.write_object(&gix::objs::Commit { + tree, + parents: parents.into_iter().collect(), + author: sig.clone(), + committer: sig, + encoding: None, + message: message.into(), + extra_headers: vec![], + }) + .map(|id| id.detach()) + .map_err(|e| error("stash commit", e)) +} +fn update(repo: &gix::Repository, id: gix::ObjectId, message: &str) -> Result<()> { + branches::edit_refs( + repo, + vec![RefEdit { + name: "refs/stash".try_into().unwrap(), + deref: false, + change: Change::Update { + expected: PreviousValue::Any, + new: Target::Object(id), + log: LogChange { + message: message.into(), + force_create_reflog: true, + ..Default::default() + }, + }, + }], + )?; + Ok(()) +} +fn drop_entry(repo: &gix::Repository, n: usize, id: gix::ObjectId, quiet: bool) -> Result<()> { + let mut rows = logs(repo)?; + let offset = rows + .len() + .checked_sub(n + 1) + .ok_or_else(|| error("stash", "no such stash"))?; + rows.remove(offset); + if rows.is_empty() { + repo.find_reference("refs/stash") + .map_err(|e| error("stash ref", e))? + .delete() + .map_err(|e| error("stash drop", e))?; + } else { + let tip = rows.last().unwrap().split_whitespace().nth(1).unwrap(); + update( + repo, + gix::ObjectId::from_hex(tip.as_bytes()).map_err(|e| error("stash id", e))?, + "stash drop", + )?; + std::fs::write(log_path(repo), format!("{}\n", rows.join("\n"))) + .map_err(|e| error("stash log", e))?; + } + if !quiet { + println!("Dropped refs/stash@{{{n}}} ({id})"); + } + Ok(()) +} +pub(crate) fn run( + repo: &gix::Repository, + command: &str, + message: Option, + entry: Option, + quiet: bool, +) -> Result { + if !matches!(command, "push" | "list" | "apply" | "pop" | "drop") { + return Err(error("stash", "unsupported subcommand")); + } + if command == "push" { + crate::merge::ensure_idle(repo)?; + let status = changes::status(repo)?; + if status.staged.is_empty() && status.unstaged.is_empty() { + if !quiet { + println!("No local changes to save"); + } + return Ok(0); + } + let head = branches::resolve(repo, "HEAD")?; + let original = repo.find_commit(head).map_err(|e| error("stash HEAD", e))?; + let subject = original + .decode() + .map_err(|e| error("stash message", e))? + .message + .to_str_lossy() + .lines() + .next() + .unwrap_or("") + .to_owned(); + let branch = crate::head_info(repo)? + .branch + .unwrap_or_else(|| "(no branch)".into()) + .trim_start_matches("refs/heads/") + .to_owned(); + let description = format!("{branch}: {} {subject}", &head.to_string()[..7]); + let index = repo.index_or_empty().map_err(|e| error("stash index", e))?; + let index_tree = staging::tree_from_index(repo, &index)?; + let index_commit = commit( + repo, + index_tree, + vec![head], + &format!("index on {description}\n"), + )?; + let work_tree = staging::tracked_tree(repo)?; + let message = message + .map(|m| format!("On {branch}: {m}")) + .unwrap_or_else(|| format!("WIP on {description}")); + let stash = commit(repo, work_tree, vec![head, index_commit], &message)?; + update(repo, stash, &message)?; + checkout::write_tree_to_worktree(repo, None, local::tree(repo, "HEAD")?, true)?; + if !quiet { + println!("Saved working directory and index state {message}"); + } + return Ok(0); + } + let rows = logs(repo)?; + if command == "list" { + for (n, row) in rows.iter().rev().enumerate() { + println!( + "stash@{{{n}}}: {}", + row.split_once('\t').map(|(_, m)| m).unwrap_or("") + ); + } + return Ok(0); + } + let n = entry + .as_deref() + .map(|s| { + s.strip_prefix("stash@{") + .and_then(|s| s.strip_suffix('}')) + .unwrap_or(s) + .parse::() + .map_err(|e| error("stash entry", e)) + }) + .transpose()? + .unwrap_or(0); + let Some(row) = rows.iter().rev().nth(n) else { + eprintln!("No stash entries found."); + return Ok(1); + }; + let id = gix::ObjectId::from_hex(row.split_whitespace().nth(1).unwrap().as_bytes()) + .map_err(|e| error("stash id", e))?; + if command == "drop" { + drop_entry(repo, n, id, quiet)?; + return Ok(0); + } + if !matches!(command, "apply" | "pop") { + eprintln!("usage: git stash [push|list|pop|apply|drop]"); + return Ok(129); + } + let stash = repo.find_commit(id).map_err(|e| error("stash commit", e))?; + let decoded = stash.decode().map_err(|e| error("stash decode", e))?; + let base = local::tree(repo, &decoded.parents[0].to_string())?; + let old_index = repo.index_or_empty().map_err(|e| error("stash index", e))?; + let ours = staging::tracked_tree(repo)?; + let (merged, mut index, conflicts) = cherry_pick::merge_trees( + repo, + base, + ours, + decoded.tree(), + "Updated upstream", + "Stashed changes", + )?; + checkout::write_tree_to_worktree(repo, Some(ours), merged, true)?; + if conflicts.is_empty() { + // Without --index, tracked modifications remain unstaged. Stashed additions stay staged. + let base_index = gix::index::State::from_tree(&base, &repo.objects, Default::default()) + .map_err(|e| error("stash base", e))?; + let added = index + .entries() + .iter() + .filter(|e| { + !base_index + .entries() + .iter() + .any(|b| b.path(&base_index) == e.path(&index)) + }) + .map(|e| e.path(&index).to_str_lossy().into_owned()) + .collect::>(); + index.remove_entries(|_, path, _| !added.contains(path.to_str_lossy().as_ref())); + for e in old_index.entries() { + if !added.contains(e.path(&old_index).to_str_lossy().as_ref()) { + index.dangerously_push_entry(e.stat, e.id, e.flags, e.mode, e.path(&old_index)); + } + } + } + staging::publish(repo, index)?; + let repo = crate::repo::open(&repo.workdir().unwrap().to_string_lossy())?; + output::conflicts(&conflicts, false); + if !quiet { + output::long_status(&changes::status(&repo)?); + } + if !conflicts.is_empty() { + return Ok(1); + } + if command == "pop" { + drop_entry(&repo, n, id, quiet)?; + } + Ok(0) +} diff --git a/userland/git/ssh-test.nu b/userland/git/ssh-test.nu new file mode 100644 index 0000000..2f7c6fc --- /dev/null +++ b/userland/git/ssh-test.nu @@ -0,0 +1,71 @@ +#!/usr/bin/env nu +# Compare SSH clone, fetch, and push contents and refs with native Git. +use std/assert +def wasi [component: path, root: path, args: list] { + let ready = (open ($root | path join ready.json)) + let result = (^wasmtime run -S inherit-network -S allow-ip-name-lookup -S http -S cli-exit-with-code=y --dir $'($root)/guest::/' --env HOME=/home --env $'SAUSAGE_HOST_API=http://127.0.0.1:($ready.api)' --env GIT_AUTHOR_NAME=Agent --env GIT_AUTHOR_EMAIL=agent@example.test --env GIT_COMMITTER_NAME=Agent --env GIT_COMMITTER_EMAIL=agent@example.test $component ...$args | complete) + if $result.exit_code != 0 {error make {msg: ($result | to json)}} + $result +} +def native [args: list] { + let result = (^git ...$args | complete) + if $result.exit_code != 0 {error make {msg: ($result | to json)}} + $result +} +def main [component: path, ssh_component: path, root: path] { + let component = ($component | path expand) + let ssh_component = ($ssh_component | path expand) + let root = ($root | path expand) + let ready = (open ($root | path join ready.json)) + let wit = (^wasm-tools component wit $component | complete) + assert equal $wit.exit_code 0 + let imports = ($wit.stdout | lines | str trim | where {|line| $line | str starts-with 'import '}) + assert (not ($imports | is-empty)) + assert ($imports | all {|line| $line | str starts-with 'import wasi:'}) + print 'Git component imports: PASS (WASI only)' + print ($imports | str join (char newline)) + wasi $ssh_component $root [-o StrictHostKeyChecking=accept-new -p $ready.ssh $'($ready.user)@127.0.0.1' true] | ignore + let seed = ($root | path join host/seed) + let bare = ($root | path join host/bare.git) + let clone = ($root | path join guest/clone) + let comparison = ($root | path join host/native-clone) + native [init -b main $seed] | ignore + native [-C $seed config user.name Agent] | ignore + native [-C $seed config user.email agent@example.test] | ignore + $"base\n" | save ($seed | path join file.txt) + native [-C $seed add .] | ignore + native [-C $seed commit --no-gpg-sign -m base] | ignore + native [clone --bare $seed $bare] | ignore + let url = $'ssh://($ready.user)@127.0.0.1:($ready.ssh)($bare)' + let ssh_command = $"ssh -F /dev/null -o IdentityAgent=none -o BatchMode=yes -i '($root)/host/client_key' -p ($ready.ssh) -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile='($root)/host/native_known_hosts'" + with-env {GIT_SSH_COMMAND: $ssh_command} { + native [clone $url $comparison] | ignore + wasi $component $root [clone $url /clone] | ignore + assert equal (open --raw ($clone | path join file.txt)) (open --raw ($comparison | path join file.txt)) + assert equal (native [-C $clone show-ref] | get stdout) (native [-C $comparison show-ref] | get stdout) + print 'SSH clone: contents and refs match native Git: PASS' + $"fetch\n" | save --force ($seed | path join file.txt) + native [-C $seed commit --no-gpg-sign -am fetch] | ignore + native [-C $seed push $bare main] | ignore + native [-C $comparison fetch] | ignore + wasi $component $root [-C /clone fetch] | ignore + assert equal (native [-C $clone show-ref] | get stdout) (native [-C $comparison show-ref] | get stdout) + assert equal (native [-C $clone show origin/main:file.txt] | get stdout) $"fetch\n" + print 'SSH fetch: contents and refs match native Git: PASS' + wasi $component $root [-C /clone config user.name Agent] | ignore + wasi $component $root [-C /clone config user.email agent@example.test] | ignore + $"pushed\n" | save ($clone | path join pushed.txt) + wasi $component $root [-C /clone add pushed.txt] | ignore + wasi $component $root [-C /clone commit -m pushed] | ignore + wasi $component $root [-C /clone push origin HEAD:wasi] | ignore + native [-C $comparison fetch] | ignore + assert equal (native [-C $bare rev-parse wasi] | get stdout) (native [-C $clone rev-parse HEAD] | get stdout) + assert equal (native [-C $comparison show origin/wasi:pushed.txt] | get stdout) $"pushed\n" + print 'SSH push: remote contents and refs match native Git: PASS' + let scp = $'($ready.user)@127.0.0.1:($bare)' + wasi $component $root [-C /clone remote add scp $scp] | ignore + assert equal (wasi $component $root [-C /clone remote get-url scp] | get stdout | str trim) $scp + print 'SCP-style remote URL: PASS' + } + print $'Fixture: ($root)' +} diff --git a/userland/git/test.nu b/userland/git/test.nu index bf35d4e..eb0c78b 100644 --- a/userland/git/test.nu +++ b/userland/git/test.nu @@ -16,15 +16,29 @@ def main [component: path] { with-env $envs { for side in [native wasi] { mkdir ($root | path join $side repo) } let steps = [ - {args: [init -b main], compare: false} + {args: [init -q -b main], compare: true} {args: [config user.name Agent], compare: true} {args: [config user.email agent@example.test], compare: true} + {args: [-c user.name=Override config --get user.name], compare: true} + {args: [config --get user.name], compare: true} {write: 'base\n', file: 'file.txt'} {args: [add .], compare: true} {args: [commit -m initial], compare: true} {args: [status --short], compare: true} + {args: [status -sb], compare: true} + {args: [status --porcelain=v1], compare: true} {args: [status], compare: true} + {args: [ls-files], compare: true} + {args: [branch --show-current], compare: true} + {args: [branch -a], compare: true} {args: [log -n 1], compare: true} + {args: [log -n 1 '--format=%H %h %s %an %ae %ad %d'], compare: true} + {args: [log -n 1 '--pretty=format:%H %h %s %an %ae %ad %d'], compare: true} + {args: [log -n 1 -p], compare: true} + {args: [log -n 1 --stat], compare: true} + {args: [log --oneline -- file.txt], compare: true} + {args: [show --stat], compare: true} + {args: [rev-parse --short HEAD], compare: true} {args: [show HEAD], compare: true} {args: [show HEAD:file.txt], compare: true} {args: [branch topic], compare: true} @@ -32,6 +46,18 @@ def main [component: path] { {write: 'topic\n', file: 'topic.txt'} {args: [add .], compare: true} {args: [commit -m topic], compare: true} + {args: [rev-parse HEAD HEAD~1], compare: true} + {args: [diff HEAD~1], compare: true} + {args: [diff HEAD~1..HEAD], compare: true} + {args: [diff --name-only HEAD~1..HEAD], compare: true} + {args: [diff --name-status HEAD~1..HEAD], compare: true} + {args: [diff HEAD~1 -- topic.txt], compare: true} + {args: [branch disposable], compare: true} + {args: [branch -d disposable], compare: true} + {args: [branch disposable], compare: true} + {args: [branch -D disposable], compare: true} + {args: [branch disposable], compare: true} + {args: [branch -q -d disposable], compare: true} {args: [switch main], compare: true} {args: [merge topic], compare: true} {write: 'changed\n', file: 'file.txt'} @@ -48,6 +74,8 @@ def main [component: path] { {args: [merge topic], compare: true, exit: 1} {args: [status --short], compare: true} {args: [merge --abort], compare: true} + {args: [merge -q topic], compare: true, exit: 1} + {args: [merge --abort], compare: true} {args: [worktree add -b linked ../linked], compare: true} {args: [worktree list], compare: false} {args: [worktree remove ../linked], compare: true} @@ -56,6 +84,7 @@ def main [component: path] { {write: 'resolved\n', file: 'file.txt'} {args: [add file.txt], compare: true} {args: [rebase --continue], compare: true} + {args: [rebase -q main], compare: true} {args: [log --oneline], compare: true} {args: [switch main], compare: true} {write: 'left\n', file: 'left.txt'} @@ -67,11 +96,137 @@ def main [component: path] { {args: [commit -m right], compare: true} {args: [switch main], compare: true} {args: [merge topic], compare: true} + {args: [show HEAD], compare: true} {args: [branch], compare: true} {args: [rm right.txt], compare: true} {args: [diff --cached], compare: true} {args: [status --porcelain], compare: true} {args: [commit -m removed], compare: true} + {args: [checkout HEAD~1], compare: true} + {args: [status], compare: true} + {args: [status -sb], compare: true} + {args: [switch main], compare: true} + {args: [checkout -q -b agent-checkout], compare: true} + {args: [switch -q main], compare: true} + {args: [switch -q -c agent-switch], compare: true} + {write: 'agent\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [commit -q -m agent], compare: true} + {args: [switch main], compare: true} + {args: [merge --no-ff -q agent-switch], compare: true} + {args: [show --stat], compare: true} + {args: [commit --quiet --allow-empty -m quiet], compare: true} + {args: [init], compare: true, normalize_path: true, state: true} + {args: [init -q], compare: true, state: true} + {args: [log -n 1 --oneline -- file.txt], compare: true} + {args: [log -n 2 --oneline -- file.txt], compare: true} + {args: [config branch.main.description agent-branch], compare: true} + {args: [branch -m rework], compare: true, state: true} + {args: [config --get branch.rework.description], compare: true} + {args: [branch -m agent-checkout renamed], compare: true, state: true} + {args: [branch], compare: true} + {args: [remote add example https://example.test/repo.git], compare: true, state: true} + {args: [remote -v], compare: true} + {args: [remote set-url example https://example.test/new.git], compare: true, state: true} + {args: [remote get-url example], compare: true} + {args: [remote remove example], compare: true, state: true} + {args: [remote -v], compare: true} + {args: [tag lightweight], compare: true, state: true} + {args: [tag -a annotated -m annotation], compare: true, state: true} + {args: [tag], compare: true} + {args: [tag --list], compare: true} + {args: [tag -d annotated], compare: true, state: true} + {args: [tag -d lightweight], compare: true, state: true} + {write: 'amended\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [commit --amend -m amended], compare: true, state: true} + {args: [commit --amend --no-edit], compare: true, state: true} + {write: 'file message\n\nbody\n', file: 'message.txt'} + {write: 'from-file\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [commit -F message.txt], compare: true, state: true} + {write: 'dirty\n', file: 'agent.txt'} + {args: [checkout agent.txt], compare: true, state: true} + {write: 'dirty\n', file: 'agent.txt'} + {args: [checkout -- agent.txt], compare: true, state: true} + {write: 'dirty\n', file: 'agent.txt'} + {args: [restore agent.txt], compare: true, state: true} + {write: 'staged\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [restore --staged agent.txt], compare: true, state: true} + {args: [status --short], compare: true} + {args: [restore agent.txt], compare: true, state: true} + {write: 'staged\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [reset HEAD agent.txt], compare: true, state: true} + {args: [restore agent.txt], compare: true, state: true} + {args: [branch saved], compare: true} + {args: [reset --soft HEAD~1], compare: true, state: true} + {args: [status --short], compare: true} + {args: [log --oneline -n 2], compare: true} + {args: [reset --hard saved], compare: true, state: true} + {args: [reset HEAD~1], compare: true, state: true} + {args: [status --short], compare: true} + {args: [reset --hard saved], compare: true, state: true} + {args: [reset --hard HEAD~1], compare: true, state: true} + {args: [reset --hard saved], compare: true, state: true} + {write: 'stash one\n', file: 'agent.txt'} + {args: [stash push], compare: true, state: true} + {args: [stash list], compare: true} + {args: [stash bogus], compare: true, exit: 128, state: true} + {args: [stash apply], compare: true, state: true} + {args: [reset --hard], compare: true, state: true} + {args: [stash drop], compare: true, state: true} + {write: 'stash two\n', file: 'agent.txt'} + {args: [stash -m custom], compare: true, state: true} + {args: [stash list], compare: true} + {write: 'stash three\n', file: 'agent.txt'} + {args: [stash], compare: true, state: true} + {args: [stash list], compare: true} + {args: [stash drop], compare: true, state: true} + {args: [stash pop], compare: true, state: true} + {args: [status --short], compare: true} + {args: [reset --hard], compare: true, state: true} + {args: [stash push], compare: true, state: true} + {write: 'added stash\n', file: 'stash-added.txt'} + {args: [add stash-added.txt], compare: true} + {write: 'stage stash\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {write: 'unstaged stash\n', file: 'agent.txt'} + {args: [stash push -m staged], compare: true, state: true} + {args: [stash apply], compare: true, state: true} + {args: [status --short], compare: true} + {args: [reset --hard], compare: true, state: true} + {args: [stash drop], compare: true, state: true} + {write: 'quiet stash\n', file: 'agent.txt'} + {args: [stash push -q -m quiet], compare: true, state: true} + {args: [stash pop -q], compare: true, state: true} + {args: [stash push -q], compare: true, state: true} + {args: [stash drop -q], compare: true, state: true} + {args: [stash push -q], compare: true, state: true} + {args: [switch -c picks], compare: true} + {write: 'picked\n', file: 'pick.txt'} + {args: [add pick.txt], compare: true} + {args: [commit -m picked], compare: true} + {args: [switch rework], compare: true} + {args: [cherry-pick picks], compare: true, state: true} + {args: [log --oneline -n 2], compare: true} + {args: [switch picks], compare: true} + {write: 'theirs\n', file: 'agent.txt'} + {args: [commit -am theirs], compare: true} + {args: [switch rework], compare: true} + {write: 'ours\n', file: 'agent.txt'} + {args: [commit -am ours], compare: true} + {args: [cherry-pick picks], compare: true, exit: 1, state: true} + {args: [status --short], compare: true} + {args: [cherry-pick --abort], compare: true, state: true} + {args: [cherry-pick picks], compare: true, exit: 1, state: true} + {write: 'resolved\n', file: 'agent.txt'} + {args: [add agent.txt], compare: true} + {args: [cherry-pick --continue], compare: true, state: true} + {args: [log --oneline -n 3], compare: true} + {args: [status --short], compare: true} + {args: [cherry-pick --abort], compare: true, exit: 128, state: true} {args: [status --bad-option], compare: true, exit: 129} {args: [not-a-command], compare: true, exit: 1} ] @@ -85,13 +240,63 @@ def main [component: path] { let native = (^git -C ($root | path join native repo) ...$args | complete) let wasi = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($root)/wasi::/' --env GIT_AUTHOR_NAME --env GIT_AUTHOR_EMAIL --env GIT_COMMITTER_NAME --env GIT_COMMITTER_EMAIL --env GIT_AUTHOR_DATE --env GIT_COMMITTER_DATE $component -C /repo ...$args | complete) let expected = ($step.exit? | default 0) - let equal = ($native.exit_code == $expected and $wasi.exit_code == $expected and (not $step.compare or $native.stdout == $wasi.stdout)) + let native_stdout = if ($step.normalize_path? | default false) { $native.stdout | str replace --all ($root | path join native repo) '/repo' } else { $native.stdout } + let equal = ($native.exit_code == $expected and $wasi.exit_code == $expected and (not $step.compare or $native_stdout == $wasi.stdout)) print $'($args | str join " "): (if $equal {"PASS"} else {"FAIL"}) native=($native.exit_code) wasi=($wasi.exit_code)' if not $equal { $failures += 1 print ({native: $native, wasi: $wasi} | to json) } + if ($step.state? | default false) { + for check in [[status --short] [log '--format=%H %P %an %ae %s' --all] [diff] [diff --cached] [show-ref] [config --get-regexp '^(branch|remote)\.']] { + let native_state = (^git -C ($root | path join native repo) ...$check | complete) + let wasi_state = (^git -C ($root | path join wasi repo) ...$check | complete) + let same = ($native_state.exit_code == $wasi_state.exit_code and $native_state.stdout == $wasi_state.stdout) + print $' state ($check | str join " "): (if $same {"PASS"} else {"FAIL"})' + if not $same { $failures += 1; print ({native: $native_state, wasi: $wasi_state} | to json) } + } + } } + # Explicit usage checks for documented forms that this CLI does not implement. + let unsupported = [ + [log --graph --oneline --all] + ] + for args in $unsupported { + let result = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($root)/wasi::/' $component -C /repo ...$args | complete) + let expected = 129 + let equal = ($result.exit_code == $expected and $result.stdout == '' and ($result.stderr | str lowercase | str contains 'usage')) + print $'($args | str join " "): (if $equal {"PASS documented usage"} else {"FAIL documented usage"}) wasi=($result.exit_code)' + if not $equal { $failures += 1; print ($result | to json) } + } + # WASI checkout and staging retain an existing executable file's indexed mode. + let executable = ($root | path join executable) + mkdir $executable + ^git -C $executable init -q -b main + "#!/bin/sh\necho executable\n" | save ($executable | path join run.sh) + ^chmod +x ($executable | path join run.sh) + ^git -C $executable add run.sh + ^git -C $executable commit --no-gpg-sign -q -m executable + rm ($executable | path join run.sh) + mut executable_ok = true + for args in [[checkout -q HEAD -- run.sh] [status --short] [diff] [add run.sh] [diff --cached] [status --short]] { + let result = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($executable)::/mode' $component -C /mode ...$args | complete) + if $result.exit_code != 0 or $result.stdout != '' { $executable_ok = false; print ($result | to json) } + } + let indexed = (^git -C $executable ls-files --stage run.sh | complete) + $executable_ok = ($executable_ok and $indexed.exit_code == 0 and ($indexed.stdout | str starts-with '100755 ')) + print $'executable checkout stays clean: (if $executable_ok {"PASS"} else {"FAIL"})' + if not $executable_ok { $failures += 1 } + # A merge tree that differs from both parents requires a combined diff. + 'combined resolution\n' | save --force ($root | path join native/repo/file.txt) + let native = ($root | path join native/repo) + ^git -C $native add file.txt + let tree = (^git -C $native write-tree | str trim) + let merge = (^git -C $native commit-tree $tree -p HEAD -p HEAD~1 -m combined | str trim) + let result = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($root)/native/repo::/combined' $component -C /combined show $merge | complete) + let native_result = (^git -C $native show $merge | complete) + let equal = ($native_result.exit_code == 0 and $result.exit_code == 129 and $result.stdout == '' and ($result.stderr | str contains 'usage')) + print $'show combined merge: (if $equal {"PASS documented usage"} else {"FAIL documented usage"}) native=($native_result.exit_code) wasi=($result.exit_code)' + if not $equal { $failures += 1; print ($result | to json) } if $failures > 0 {error make {msg: $'($failures) workflow comparisons failed'}} } } diff --git a/userland/procps/src/main.rs b/userland/procps/src/main.rs index 4ebe58c..c783b03 100644 --- a/userland/procps/src/main.rs +++ b/userland/procps/src/main.rs @@ -5,7 +5,7 @@ mod bindings { fn main() { let args: Vec = std::env::args().collect(); match std::path::Path::new(&args[0]) - .file_name() + .file_stem() .unwrap() .to_str() .unwrap() diff --git a/userland/spawn/src/lib.rs b/userland/spawn/src/lib.rs index 772cb72..a12ef9b 100644 --- a/userland/spawn/src/lib.rs +++ b/userland/spawn/src/lib.rs @@ -44,13 +44,7 @@ impl Command { pub fn stderr(&mut self, mode: Stdio) -> &mut Self { self.stderr = mode; self } /// Resolve PATH and start the component. pub fn spawn(&self) -> io::Result { - let program = if self.program.contains('/') { self.program.clone() } else { - self.env.get("PATH").map(String::as_str).unwrap_or("/usr/local/bin:/bin").split(':') - .map(|dir| self.cwd.join(dir).join(&self.program)) - .find(|p| p.is_file()).ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, &self.program[..]))? - .to_string_lossy().into_owned() - }; - let child = host::spawn(&host::Request { program, argv: std::iter::once(self.program.clone()).chain(self.args.clone()).collect(), + let child = host::spawn(&host::Request { program: self.program.clone(), argv: std::iter::once(self.program.clone()).chain(self.args.clone()).collect(), env: self.env.clone().into_iter().collect(), cwd: self.cwd.to_string_lossy().into_owned(), group: self.group, stdin: self.stdin, stdout: self.stdout, stderr: self.stderr }).map_err(io::Error::other)?; Ok(Child { stdin: matches!(self.stdin, Stdio::Pipe).then(|| child.stdin()), diff --git a/userland/ssh-client/Cargo.toml b/userland/ssh-client/Cargo.toml new file mode 100644 index 0000000..c49a8f1 --- /dev/null +++ b/userland/ssh-client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "sausage-ssh" +version = "0.1.0" +edition = "2024" +description = "WASI SSH2 transport with host signing and OpenSSH host trust" +[dependencies] +ssh-key = { version = "0.6.7", default-features = false, features = ["std", "ed25519"] } +x25519-dalek = { version = "2", features = ["static_secrets"] } +rand_core = { version = "0.6", features = ["getrandom"] } +sha2 = "0.10" +sha1 = "0.10" +hmac = "0.12" +aes = "0.8" +ctr = "0.9" +signature = "2" +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +url = "2" +futures-lite = "2" +wasi = "0.14" diff --git a/userland/ssh-client/README.md b/userland/ssh-client/README.md new file mode 100644 index 0000000..2ca6f46 --- /dev/null +++ b/userland/ssh-client/README.md @@ -0,0 +1,17 @@ +# Shared SSH client + +This library ports v2's Rust SSH transport for the `ssh` and `git` CLIs. +It uses standard WASI sockets for TCP and standard WASI HTTP for host-mediated authentication. +It never reads a private-key file or a v2 configuration file. + +`Session` owns key exchange, authentication, exec channels, flow control, and packet protection. +`identity::connect` obtains a public identity and signs authentication through the host API. +`Session::split` supplies Git's in-process transport with a reader and a writer. +`Session::execute_input` sends buffered stdin and captures both output streams and the remote exit status. +Host trust uses `$HOME/.ssh/known_hosts`, including hashed names, patterns, and revocations. + +The protocol supports curve25519-sha256, Ed25519, AES-128-CTR, and HMAC-SHA2, including OpenSSH EtM variants. +The inherited TCP defaults use a five-second connection budget and fifteen-second read and write timeouts. + +The integration tests in `../ssh/test.nu` and `../git/ssh-test.nu` use native sshd and a fake signing server. +The fake server keeps its private keys outside the guest preopen. diff --git a/userland/ssh-client/src/command.rs b/userland/ssh-client/src/command.rs new file mode 100644 index 0000000..0af6872 --- /dev/null +++ b/userland/ssh-client/src/command.rs @@ -0,0 +1,118 @@ +//! Absolute-deadline blocking TCP adapter for single-command clients. +#[cfg(not(target_os = "wasi"))] +use std::net::ToSocketAddrs; +use std::{ + io::{self, Read, Write}, + net::{SocketAddr, TcpStream}, + time::Instant, +}; + +#[cfg(not(target_os = "wasi"))] +fn resolve(host: &str, port: u16, _deadline: Instant) -> io::Result> { + Ok((host, port).to_socket_addrs()?.take(8).collect()) +} +#[cfg(target_os = "wasi")] +fn resolve(host: &str, port: u16, deadline: Instant) -> io::Result> { + use wasi::sockets::{ip_name_lookup, network::IpAddress}; + if let Ok(ip) = host.parse::() { + return Ok(vec![SocketAddr::new(ip, port)]); + } + let network = wasi::sockets::instance_network::instance_network(); + let addresses = ip_name_lookup::resolve_addresses(&network, host) + .map_err(|_| io::Error::other("DNS resolution failed"))?; + let ready = addresses.subscribe(); + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(timeout)?; + let timer = wasi::clocks::monotonic_clock::subscribe_duration( + remaining.as_nanos().min(u64::MAX as u128) as u64, + ); + let mut result = Vec::new(); + loop { + if Instant::now() >= deadline { + return Err(timeout()); + } + match addresses.resolve_next_address() { + Ok(Some(ip)) => { + let ip = match ip { + IpAddress::Ipv4((a, b, c, d)) => { + std::net::IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, d)) + } + IpAddress::Ipv6((a, b, c, d, e, f, g, h)) => { + std::net::IpAddr::V6(std::net::Ipv6Addr::new(a, b, c, d, e, f, g, h)) + } + }; + result.push(SocketAddr::new(ip, port)); + if result.len() == 8 { + break; + } + } + Ok(None) => break, + Err(wasi::sockets::network::ErrorCode::WouldBlock) => { + if wasi::io::poll::poll(&[&ready, &timer]).contains(&1) { + return Err(timeout()); + } + } + Err(_) => return Err(io::Error::other("DNS resolution failed")), + } + } + Ok(result) +} + +pub(crate) fn timeout() -> io::Error { + io::Error::new(io::ErrorKind::TimedOut, "timeout") +} +/// Captured raw streams, sharing a single byte budget. None means no exit-status (e.g. signal). +#[derive(Default, Debug)] +pub struct CommandOutput { + pub exit_code: Option, + pub stdout: Vec, + pub stderr: Vec, + pub truncated: bool, +} +/// Each underlying operation checks time, including peers sending tiny packets indefinitely. +pub struct DeadlineTcp { + stream: TcpStream, + deadline: Instant, +} +impl DeadlineTcp { + pub fn connect(host: &str, port: u16, deadline: Instant) -> io::Result { + let addresses = resolve(host, port, deadline)?; + let mut last = io::Error::new(io::ErrorKind::ConnectionRefused, "connection refused"); + for address in addresses { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(timeout)?; + match TcpStream::connect_timeout(&address, remaining) { + Ok(stream) => return Ok(Self { stream, deadline }), + Err(e) => last = e, + } + } + Err(last) + } + fn prepare(&self) -> io::Result<()> { + let remaining = self + .deadline + .checked_duration_since(Instant::now()) + .filter(|d| !d.is_zero()) + .ok_or_else(timeout)?; + self.stream.set_read_timeout(Some(remaining))?; + self.stream.set_write_timeout(Some(remaining)) + } +} +impl Read for DeadlineTcp { + fn read(&mut self, b: &mut [u8]) -> io::Result { + self.prepare()?; + self.stream.read(b) + } +} +impl Write for DeadlineTcp { + fn write(&mut self, b: &[u8]) -> io::Result { + self.prepare()?; + self.stream.write(b) + } + fn flush(&mut self) -> io::Result<()> { + self.prepare()?; + self.stream.flush() + } +} diff --git a/userland/ssh-client/src/identity.rs b/userland/ssh-client/src/identity.rs new file mode 100644 index 0000000..faf5f1f --- /dev/null +++ b/userland/ssh-client/src/identity.rs @@ -0,0 +1,128 @@ +//! Public SSH identities and signing through the host's standard HTTP API. +use crate::{Session, Stream, connect_tcp, ssh_wire::fail}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::io; +use wasi::http::{outgoing_handler, types::*}; + +#[derive(Deserialize)] +pub struct Identity { + pub item: String, + pub public_key: String, + pub user: Option, +} +fn post(route: &str, payload: Value) -> io::Result { + let base = std::env::var("SAUSAGE_HOST_API").unwrap_or_else(|_| "http://host.sausage".into()); + let url = url::Url::parse(&format!("{}{route}", base.trim_end_matches('/'))) + .map_err(|_| fail("invalid host API URL"))?; + let bytes = serde_json::to_vec(&payload).map_err(|e| fail(&e.to_string()))?; + let headers = Fields::new(); + headers + .set("content-type", &[b"application/json".to_vec()]) + .map_err(|_| fail("HTTP headers"))?; + headers + .set("content-length", &[bytes.len().to_string().into_bytes()]) + .map_err(|_| fail("HTTP headers"))?; + let request = OutgoingRequest::new(headers); + request + .set_method(&Method::Post) + .map_err(|_| fail("HTTP method"))?; + request + .set_scheme(Some(&Scheme::Http)) + .map_err(|_| fail("HTTP scheme"))?; + request + .set_authority(Some( + &url[url::Position::BeforeHost..url::Position::AfterPort], + )) + .map_err(|_| fail("HTTP authority"))?; + request + .set_path_with_query(Some(url.path())) + .map_err(|_| fail("HTTP path"))?; + let body = request.body().map_err(|_| fail("HTTP body"))?; + let stream = body.write().map_err(|_| fail("HTTP stream"))?; + let response = + outgoing_handler::handle(request, None).map_err(|e| fail(&format!("host API: {e:?}")))?; + stream + .blocking_write_and_flush(&bytes) + .map_err(|_| fail("host API upload"))?; + drop(stream); + OutgoingBody::finish(body, None).map_err(|_| fail("host API upload finish"))?; + response.subscribe().block(); + let response = response + .get() + .ok_or_else(|| fail("host API response missing"))? + .map_err(|_| fail("host API response consumed"))? + .map_err(|e| fail(&format!("host API: {e:?}")))?; + let status = response.status(); + let body = response + .consume() + .map_err(|_| fail("host API response body"))?; + let stream = body + .stream() + .map_err(|_| fail("host API response stream"))?; + let mut bytes = Vec::new(); + loop { + match stream.blocking_read(65536) { + Ok(data) if data.is_empty() => stream.subscribe().block(), + Ok(data) => bytes.extend(data), + Err(wasi::io::streams::StreamError::Closed) => break, + Err(_) => return Err(fail("host API response read")), + } + } + drop(stream); + let trailers = IncomingBody::finish(body); + trailers.subscribe().block(); + if !matches!(trailers.get(), Some(Ok(Ok(_)))) { + return Err(fail("host API response finish")); + } + if status != 200 { + return Err(fail(&format!( + "host API HTTP {status}: {}", + String::from_utf8_lossy(&bytes) + ))); + } + serde_json::from_slice(&bytes).map_err(|_| fail("invalid host API JSON")) +} +impl Identity { + pub fn load(host: &str) -> io::Result { + serde_json::from_value(post("/ssh/identity", json!({"host": host}))?) + .map_err(|_| fail("invalid SSH identity")) + } + pub fn sign(&self, host: &str, payload: Vec) -> io::Result> { + let result = post( + "/ssh/sign", + json!({"host":host,"item":self.item,"payload":STANDARD.encode(payload)}), + )?; + STANDARD + .decode( + result["signature"] + .as_str() + .ok_or_else(|| fail("missing signature"))?, + ) + .map_err(|_| fail("invalid signature")) + } +} +pub fn connect( + host: &str, + port: u16, + user: Option<&str>, + accept_new: bool, +) -> io::Result>> { + let identity = Identity::load(host)?; + let user = user + .or(identity.user.as_deref()) + .filter(|u| !u.is_empty()) + .ok_or_else(|| fail("specify an SSH user"))?; + let public = ssh_key::PublicKey::from_bytes( + &STANDARD + .decode(&identity.public_key) + .map_err(|_| fail("invalid public key"))?, + ) + .map_err(|_| fail("invalid public key"))?; + let mut session = Session::start(connect_tcp(host, port)?, host.into(), port, accept_new)?; + futures_lite::future::block_on(session.authenticate_with(user, &public, |payload| { + std::future::ready(identity.sign(host, payload)) + }))?; + Ok(session) +} diff --git a/userland/ssh-client/src/lib.rs b/userland/ssh-client/src/lib.rs new file mode 100644 index 0000000..ce0d7ef --- /dev/null +++ b/userland/ssh-client/src/lib.rs @@ -0,0 +1,9 @@ +//! SSH2 sessions, world-owned host trust, and host-mediated signing for WASI programs. +mod command; +pub mod identity; +pub mod ssh_hosts; +pub mod ssh_kex; +pub mod ssh_wire; +pub use command::{CommandOutput, DeadlineTcp}; +mod session; +pub use session::*; diff --git a/userland/ssh-client/src/session.rs b/userland/ssh-client/src/session.rs new file mode 100644 index 0000000..fc5ad17 --- /dev/null +++ b/userland/ssh-client/src/session.rs @@ -0,0 +1,463 @@ +//! Blocking SSH2 exec transport. No shell/process on the WASI host. +//! Host trust uses the existing lock-free known_hosts check/append policy. +use super::command::{CommandOutput, timeout}; +use super::{ssh_hosts, ssh_kex, ssh_wire::*}; +use std::{ + cell::RefCell, + collections::VecDeque, + io::{self, Read, Write}, + net::{TcpStream, ToSocketAddrs}, + rc::Rc, + time::{Duration, Instant}, +}; +const WINDOW: u32 = 1024 * 1024; +include!("ssh_channel_tests.inc"); + +pub trait Stream: Read + Write {} +impl Stream for T {} +/// TCP connection and per-operation I/O budgets. The overall budget starts after DNS. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TcpTimeouts { + pub overall: Duration, + pub connect: Duration, + pub read: Duration, + pub write: Duration, +} +impl Default for TcpTimeouts { + fn default() -> Self { + Self { + overall: Duration::from_secs(5), + connect: Duration::from_secs(2), + read: Duration::from_secs(15), + write: Duration::from_secs(15), + } + } +} +pub fn connect_tcp(host: &str, port: u16) -> io::Result> { + connect_tcp_with_timeouts(host, port, TcpTimeouts::default()) +} +pub fn connect_tcp_with_timeouts( + host: &str, + port: u16, + timeouts: TcpTimeouts, +) -> io::Result> { + Ok(Box::new(tcp_stream(host, port, timeouts)?)) +} +fn tcp_stream(host: &str, port: u16, timeouts: TcpTimeouts) -> io::Result { + let addresses = (host, port) + .to_socket_addrs() + .map_err(|_| fail("DNS resolution failed"))?; + let deadline = Instant::now() + timeouts.overall; + for address in addresses.take(8) { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + break; + }; + if let Ok(stream) = TcpStream::connect_timeout(&address, remaining.min(timeouts.connect)) { + stream.set_read_timeout(Some(timeouts.read))?; + stream.set_write_timeout(Some(timeouts.write))?; + return Ok(stream); + } + } + Err(fail(&format!( + "TCP connection failed or timed out ({}s)", + timeouts.overall.as_secs() + ))) +} +#[cfg(test)] +mod timeout_tests { + use super::*; + #[test] + fn tcp_socket_uses_default_and_overridden_io_timeouts() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + for timeouts in [ + TcpTimeouts::default(), + TcpTimeouts { + overall: Duration::from_secs(7), + connect: Duration::from_secs(3), + read: Duration::from_secs(11), + write: Duration::from_secs(13), + }, + ] { + let stream = tcp_stream("127.0.0.1", port, timeouts).unwrap(); + let (_peer, _) = listener.accept().unwrap(); + assert_eq!(stream.read_timeout().unwrap(), Some(timeouts.read)); + assert_eq!(stream.write_timeout().unwrap(), Some(timeouts.write)); + } + } +} +pub struct Session { + pub(crate) packets: Packets, + version: Vec, + id: Vec, + host: String, + port: u16, + accept_new: bool, + remote: u32, + send_window: u32, + send_max: u32, + receive_window: u32, + data: VecDeque, + eof: bool, + closed: bool, + exit: Option, + capture: Option, + capture_limit: usize, + deadline: Option, +} +impl Session { + pub fn start(mut stream: S, host: String, port: u16, accept_new: bool) -> io::Result { + let version = ssh_kex::banner(&mut stream)?; + let mut packets = Packets::new(stream); + let mut id = Vec::new(); + ssh_kex::exchange(&mut packets, &version, &mut id, None, |key| { + ssh_hosts::verify(&host, port, key, accept_new) + })?; + Ok(Self { + packets, + version, + id, + host, + port, + accept_new, + remote: 0, + send_window: 0, + send_max: 0, + receive_window: WINDOW, + data: VecDeque::new(), + eof: false, + closed: false, + exit: None, + capture: None, + capture_limit: 0, + deadline: None, + }) + } + fn next(&mut self) -> io::Result> { + for _ in 0..256 { + if self.deadline.is_some_and(|d| Instant::now() >= d) { + return Err(timeout()); + } + let p = self.packets.meaningful()?; + match p.first() { + Some(20) => { + let (host, port, accept_new) = (&self.host, self.port, self.accept_new); + ssh_kex::exchange( + &mut self.packets, + &self.version, + &mut self.id, + Some(p), + |key| ssh_hosts::verify(host, port, key, accept_new), + )?; + } + Some(80) => { + let mut d = Decoder(&p[1..]); + d.string()?; + if d.byte()? != 0 { + self.packets.write(&[82])?; + } + } + Some(_) => return Ok(p), + None => return Err(fail("empty packet")), + } + } + Err(fail("too many non-progress transport messages")) + } + /// Authenticate without holding a private key. The callback returns an SSH-encoded signature. + /// Async permits a vault endpoint round trip; no retries or key bytes cross this API. + pub async fn authenticate_with( + &mut self, + user: &str, + public: &ssh_key::PublicKey, + sign: F, + ) -> io::Result<()> + where + F: FnOnce(Vec) -> Fut, + Fut: std::future::Future>>, + { + self.packets.write(&message(5, &[b"ssh-userauth"]))?; + let accept = self.next()?; + if accept != message(6, &[b"ssh-userauth"]) { + return Err(fail("userauth service rejected")); + } + if public.algorithm() != ssh_key::Algorithm::Ed25519 { + return Err(fail("only ed25519 authentication supported")); + } + let public = public + .to_bytes() + .map_err(|_| fail("could not encode public key"))?; + let mut request = message(50, &[user.as_bytes(), b"ssh-connection", b"publickey"]); + request.push(1); + string(&mut request, b"ssh-ed25519"); + string(&mut request, &public); + let mut signed = Vec::new(); + string(&mut signed, &self.id); + signed.extend_from_slice(&request); + let signature = sign(signed).await?; + string(&mut request, &signature); + self.packets.write(&request)?; + for _ in 0..16 { + match self.next()?.first() { + Some(52) => return Ok(()), + Some(53) => continue, // banner, never logged + Some(51) => return Err(fail("auth failed: publickey auth rejected")), + _ => return Err(fail("unexpected authentication reply")), + } + } + Err(fail("too many authentication banners")) + } + pub fn open_command(&mut self, command: &[u8]) -> io::Result<()> { + if command.is_empty() || command.contains(&0) { + return Err(fail("invalid exec command")); + } + let mut p = message(90, &[b"session"]); + uint(&mut p, 0); + uint(&mut p, WINDOW); + uint(&mut p, 32768); + self.packets.write(&p)?; + let reply = self.next()?; + let mut d = Decoder(&reply); + if d.byte()? != 91 || d.uint()? != 0 { + return Err(fail("exec channel open rejected")); + } + self.remote = d.uint()?; + self.send_window = d.uint()?; + self.send_max = d.uint()?.min(32768); + if self.send_max == 0 { + return Err(fail("invalid channel max packet size")); + } + let mut p = vec![98]; + uint(&mut p, self.remote); + string(&mut p, b"exec"); + p.push(1); + string(&mut p, command); + self.packets.write(&p)?; + for _ in 0..256 { + let p = self.next()?; + if p.first() == Some(&99) { + let mut d = Decoder(&p[1..]); + if d.uint()? != 0 { + return Err(fail("wrong channel in exec reply")); + } + return Ok(()); + } + if p.first() == Some(&100) { + return Err(fail("remote exec request rejected")); + } + self.handle(p)?; + if self.closed { + return Err(fail("channel closed before exec confirmation")); + } + } + Err(fail("exec confirmation missing")) + } + /// Run exactly one exec channel, send stdin EOF, drain both streams through CLOSE. + /// The stream must enforce the same absolute deadline for blocking reads/writes. + pub fn execute( + self, + command: &[u8], + max_bytes: usize, + deadline: Instant, + ) -> io::Result { + self.execute_input(command, &[], max_bytes, deadline) + } + /// Execute a command with buffered stdin and capture both output streams. + pub fn execute_input( + mut self, + command: &[u8], + mut input: &[u8], + max_bytes: usize, + deadline: Instant, + ) -> io::Result { + self.deadline = Some(deadline); + self.capture_limit = max_bytes; + self.capture = Some(CommandOutput::default()); + self.open_command(command)?; + while !input.is_empty() { + let sent = self.write_data(input)?; + input = &input[sent..]; + } + let mut eof = vec![96]; + uint(&mut eof, self.remote); + self.packets.write(&eof)?; + while !self.closed { + let p = self.next()?; + self.handle(p)?; + } + let mut output = self + .capture + .take() + .ok_or_else(|| fail("missing command result"))?; + output.exit_code = self.exit; + Ok(output) + } + fn replenish(&mut self, count: u32) -> io::Result<()> { + if count == 0 || self.closed { + return Ok(()); + } + self.receive_window = self + .receive_window + .checked_add(count) + .filter(|w| *w <= WINDOW) + .ok_or_else(|| fail("channel receive window overflow"))?; + let mut p = vec![93]; + uint(&mut p, self.remote); + uint(&mut p, count); + self.packets.write(&p) + } + fn handle(&mut self, p: Vec) -> io::Result<()> { + let mut d = Decoder(&p); + let kind = d.byte()?; + if !(93..=100).contains(&kind) { + return Err(fail("unexpected SSH channel message")); + } + if d.uint()? != 0 { + return Err(fail("wrong SSH channel recipient")); + } + match kind { + 93 => { + self.send_window = self + .send_window + .checked_add(d.uint()?) + .ok_or_else(|| fail("channel send window overflow"))?; + } + 94 | 95 => { + if self.eof { + return Err(fail("data after channel EOF")); + } + if kind == 95 { + d.uint()?; + } + let data = d.string()?; + if data.len() > 32768 || data.len() > self.receive_window as usize { + return Err(fail("channel receive window exceeded")); + } + self.receive_window -= data.len() as u32; + if let Some(output) = &mut self.capture { + let remaining = self + .capture_limit + .saturating_sub(output.stdout.len() + output.stderr.len()); + let n = remaining.min(data.len()); + output.truncated |= n < data.len(); + if kind == 94 { + output.stdout.extend_from_slice(&data[..n]); + } else { + output.stderr.extend_from_slice(&data[..n]); + } + self.replenish(data.len() as u32)?; + } else if kind == 94 { + self.data.extend(data); + } else { + self.replenish(data.len() as u32)?; + } // Git discards stderr + } + 96 => { + self.eof = true; + } + 97 => { + if !self.closed { + let mut p = vec![97]; + uint(&mut p, self.remote); + self.packets.write(&p)?; + } + self.closed = true; + self.eof = true; + } + 98 => { + let request = d.string()?; + let want_reply = d.byte()? != 0; + match request { + b"exit-status" => self.exit = Some(d.uint()?), + b"exit-signal" if self.capture.is_some() => {} + b"exit-signal" => return Err(fail("remote git terminated by signal")), + _ => {} + } + if want_reply { + let mut p = vec![100]; + uint(&mut p, self.remote); + self.packets.write(&p)?; + } + } + _ => return Err(fail("unsolicited channel request result")), + } + Ok(()) + } + fn read_data(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + for _ in 0..1024 { + if !self.data.is_empty() { + let n = buf.len().min(self.data.len()); + for b in &mut buf[..n] { + *b = self + .data + .pop_front() + .ok_or_else(|| fail("channel buffer underflow"))?; + } + self.replenish(n as u32)?; + return Ok(n); + } + if self.exit.is_some_and(|n| n != 0) { + return Err(fail("remote git exited unsuccessfully")); + } + if self.eof { + return Ok(0); + } + let p = self.next()?; + self.handle(p)?; + } + Err(fail("too many non-progress channel messages")) + } + fn write_data(&mut self, buf: &[u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + if self.closed { + return Err(fail("channel closed")); + } + for _ in 0..1024 { + if self.send_window != 0 { + let n = buf + .len() + .min(self.send_window as usize) + .min(self.send_max as usize); + let mut p = vec![94]; + uint(&mut p, self.remote); + string(&mut p, &buf[..n]); + self.packets.write(&p)?; + self.send_window -= n as u32; + return Ok(n); + } + let p = self.next()?; + self.handle(p)?; + if self.closed { + return Err(fail("channel closed")); + } + } + Err(fail("channel send window stalled")) + } +} +pub struct Reader(Rc>>>); +pub struct Writer(Rc>>>); +impl Read for Reader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.0.borrow_mut().read_data(buf) + } +} +impl Write for Writer { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.borrow_mut().write_data(buf) + } + fn flush(&mut self) -> io::Result<()> { + self.0.borrow_mut().packets.stream.flush() + } +} + +impl Session> { + /// Split one channel into blocking reader/writer halves, as used by gitoxide. + pub fn split(self) -> (Reader, Writer) { + let shared = Rc::new(RefCell::new(self)); + (Reader(shared.clone()), Writer(shared)) + } +} diff --git a/userland/ssh-client/src/ssh_channel_tests.inc b/userland/ssh-client/src/ssh_channel_tests.inc new file mode 100644 index 0000000..06c1304 --- /dev/null +++ b/userland/ssh-client/src/ssh_channel_tests.inc @@ -0,0 +1,43 @@ +#[cfg(test)] +mod channel_tests { + use super::*; + use std::io::Cursor; + struct Wire { input: Cursor>, output: Vec } + impl Read for Wire { fn read(&mut self, b: &mut [u8]) -> io::Result { self.input.read(b) } } + impl Write for Wire { + fn write(&mut self, b: &[u8]) -> io::Result { self.output.extend(b); Ok(b.len()) } + fn flush(&mut self) -> io::Result<()> { Ok(()) } + } + fn session() -> Session { + Session { packets: Packets::new(Wire { input: Cursor::new(vec![]), output: vec![] }), version: vec![], id: vec![], + host: "fake.example".into(), port: 22, accept_new: false, remote: 42, send_window: 10, send_max: 4, receive_window: WINDOW, + data: VecDeque::new(), eof: false, closed: false, exit: None, + capture: None, capture_limit: 0, deadline: None } + } + fn channel(kind: u8, data: &[u8]) -> Vec { + let mut p = vec![kind]; uint(&mut p, 0); p.extend(data); p + } + #[test] + fn channel_data_stderr_window_eof_close() { + let mut s = session(); + let mut data = vec![]; string(&mut data, b"hello"); s.handle(channel(94, &data)).unwrap(); + assert_eq!(s.receive_window, WINDOW-5); + let mut stderr = vec![]; uint(&mut stderr, 1); string(&mut stderr, b"discarded stderr"); s.handle(channel(95, &stderr)).unwrap(); + assert_eq!(s.receive_window, WINDOW-5); + s.handle(channel(96, &[])).unwrap(); + let mut buffer = [0;10]; assert_eq!(s.read_data(&mut buffer).unwrap(),5); assert_eq!(&buffer[..5], b"hello"); + assert_eq!(s.receive_window, WINDOW); assert_eq!(s.read_data(&mut buffer).unwrap(), 0); + assert!(s.handle(channel(94, &data)).is_err()); + s.handle(channel(97, &[])).unwrap(); assert!(s.closed); assert!(s.write_data(b"after close").is_err()); + } + #[test] + fn channel_send_chunks_reject_overflow_and_failed_exit() { + let mut s = session(); assert_eq!(s.write_data(b"0123456789").unwrap(), 4); assert_eq!(s.send_window, 6); + let mut wrong = vec![93]; uint(&mut wrong, 99); uint(&mut wrong, 1); assert!(s.handle(wrong).is_err()); + let mut adjustment = vec![]; uint(&mut adjustment, u32::MAX); assert!(s.handle(channel(93,&adjustment)).is_err()); + let mut s = session(); s.receive_window = 1; + let mut data = vec![]; string(&mut data, b"over window"); assert!(s.handle(channel(94,&data)).is_err()); + let mut status = vec![]; string(&mut status, b"exit-status"); status.push(0); uint(&mut status, 1); + s.handle(channel(98,&status)).unwrap(); assert!(s.read_data(&mut [0]).is_err()); + } +} diff --git a/userland/ssh-client/src/ssh_hosts.rs b/userland/ssh-client/src/ssh_hosts.rs new file mode 100644 index 0000000..9318e0a --- /dev/null +++ b/userland/ssh-client/src/ssh_hosts.rs @@ -0,0 +1,239 @@ +//! OpenSSH known_hosts policy: explicit keys, hashed names, patterns and revocations. +use super::ssh_wire::fail; +use base64::{Engine, engine::general_purpose::STANDARD}; +use hmac::{Hmac, Mac}; +use std::io::{self, Write}; + +fn pattern(pattern: &[u8], name: &[u8]) -> bool { + // Linear glob matcher with a single backtracking '*' position, no recursion. + let (mut p, mut n, mut star, mut retry) = (0, 0, None, 0); + while n < name.len() { + if p < pattern.len() && (pattern[p] == b'?' || pattern[p].eq_ignore_ascii_case(&name[n])) { + p += 1; + n += 1; + } else if p < pattern.len() && pattern[p] == b'*' { + star = Some(p); + p += 1; + retry = n; + } else if let Some(s) = star { + retry += 1; + n = retry; + p = s + 1; + } else { + return false; + } + } + while p < pattern.len() && pattern[p] == b'*' { + p += 1; + } + p == pattern.len() +} +fn matches(names: &str, host: &str) -> bool { + if names.starts_with('|') { + let parts: Vec<_> = names.split('|').collect(); + if parts.len() != 4 || parts[1] != "1" { + return false; + } + let (Ok(salt), Ok(hash)) = (STANDARD.decode(parts[2]), STANDARD.decode(parts[3])) else { + return false; + }; + let Ok(mut mac) = as Mac>::new_from_slice(&salt) else { + return false; + }; + mac.update(host.as_bytes()); + return mac.verify_slice(&hash).is_ok(); + } + let mut found = false; + for part in names.split(',') { + if let Some(negated) = part.strip_prefix('!') { + if pattern(negated.as_bytes(), host.as_bytes()) { + return false; + } + } else if pattern(part.as_bytes(), host.as_bytes()) { + found = true; + } + } + found +} +/// Returns false for an unknown host; mismatched/revoked keys are always errors. +pub fn check(text: &str, host: &str, key: &[u8]) -> io::Result { + let mut seen = false; + let mut matched = false; + for line in text.lines() { + let mut fields = line.split_whitespace(); + let Some(first) = fields.next() else { + continue; + }; + if first.starts_with('#') { + continue; + } + let (marker, names) = if first.starts_with('@') { + (Some(first), fields.next().unwrap_or("")) + } else { + (None, first) + }; + if !matches(names, host) { + continue; + } + let algorithm = fields + .next() + .ok_or_else(|| fail("malformed known_hosts entry"))?; + let encoded = fields + .next() + .ok_or_else(|| fail("malformed known_hosts entry"))?; + let bytes = STANDARD + .decode(encoded) + .map_err(|_| fail("malformed known_hosts key"))?; + if marker == Some("@cert-authority") { + continue; + } // certificates not negotiated + if marker.is_some() && marker != Some("@revoked") { + return Err(fail("unsupported known_hosts marker")); + } + if marker == Some("@revoked") { + if bytes == key { + return Err(fail( + "Host key verification failed: revoked key in ~/.ssh/known_hosts", + )); + } + continue; + } + // Other algorithms for this host are not a changed ed25519 key. + if algorithm == "ssh-ed25519" { + seen = true; + if bytes == key { + matched = true; + } + } + } + if matched { + Ok(true) + } else if seen { + Err(fail( + "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! Host key verification failed.", + )) + } else { + Ok(false) + } +} +/// No global lock: network operations in unrelated repositories must stay independent. +/// Accept-new uses append mode, never read/modify/replace. Racing checks may append +/// identical entries; these are harmless. External trust-file edits must still be +/// coordinated by the person (as with any concurrent known_hosts editor). +pub fn verify(host: &str, port: u16, key: &[u8], accept_new: bool) -> io::Result<()> { + let host = if port == 22 { + host.to_owned() + } else { + format!("[{host}]:{port}") + }; + let folder = std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_else(|| "/".into())) + .join(".ssh"); + std::fs::create_dir_all(&folder)?; + let path = folder.join("known_hosts"); + let text = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), + Err(_) => return Err(fail("could not read ~/.ssh/known_hosts")), + }; + if check(&text, &host, key)? { + return Ok(()); + } + if !accept_new { + use sha2::Digest; + let fingerprint = + base64::engine::general_purpose::STANDARD_NO_PAD.encode(sha2::Sha256::digest(key)); + return Err(fail(&format!( + "No ED25519 host key is known for {host} and you have requested strict checking. Host key verification failed. SHA256:{fingerprint}" + ))); + } + let prefix = if text.is_empty() || text.ends_with('\n') { + "" + } else { + "\n" + }; + let line = format!("{prefix}{host} ssh-ed25519 {}\n", STANDARD.encode(key)); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path)?; + file.write_all(line.as_bytes())?; + file.sync_all() +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn hosts_plain_hashed_changed_revoked() { + let key = b"synthetic-host-key"; + let encoded = STANDARD.encode(key); + assert!( + check( + &format!("*.example,!bad.example ssh-ed25519 {encoded}"), + "git.example", + key + ) + .unwrap() + ); + assert!( + !check( + &format!("*.example,!bad.example ssh-ed25519 {encoded}"), + "bad.example", + key + ) + .unwrap() + ); + assert!( + check( + &format!("git.example ssh-ed25519 {encoded}"), + "git.example", + b"changed" + ) + .is_err() + ); + assert!( + check( + &format!( + "git.example ssh-ed25519 {encoded}\n@revoked git.example ssh-ed25519 {encoded}" + ), + "git.example", + key + ) + .is_err() + ); + let salt = b"01234567890123456789"; + let mut mac = as Mac>::new_from_slice(salt).unwrap(); + mac.update(b"[git.example]:2222"); + let names = format!( + "|1|{}|{}", + STANDARD.encode(salt), + STANDARD.encode(mac.finalize().into_bytes()) + ); + assert!( + check( + &format!("{names} ssh-ed25519 {encoded}"), + "[git.example]:2222", + key + ) + .unwrap() + ); + assert!( + !check( + &format!("{names} ssh-ed25519 {encoded}"), + "git.example", + key + ) + .unwrap() + ); + } + #[test] + fn duplicate_appends_preserve_trust_policy() { + // Model two accept-new callers that both read an unknown host before appending. + let key = b"synthetic-host-key"; + let line = format!("git.example ssh-ed25519 {}\n", STANDARD.encode(key)); + let duplicated = line.repeat(2); + assert!(check(&duplicated, "git.example", key).unwrap()); + assert!(check(&duplicated, "git.example", b"changed").is_err()); + let revoked = format!("{duplicated}@revoked {line}"); + assert!(check(&revoked, "git.example", key).is_err()); + } +} diff --git a/userland/ssh-client/src/ssh_kex.rs b/userland/ssh-client/src/ssh_kex.rs new file mode 100644 index 0000000..9a66602 --- /dev/null +++ b/userland/ssh-client/src/ssh_kex.rs @@ -0,0 +1,223 @@ +//! RFC8731 curve25519-sha256, RFC4253 key derivation, ed25519 host signatures. +include!("ssh_negotiation_tests.inc"); +use super::ssh_wire::*; +use sha2::{Digest, Sha256}; +use signature::Verifier; +use std::io::{self, Read, Write}; +use x25519_dalek::{PublicKey, StaticSecret}; + +pub const VERSION: &[u8] = b"SSH-2.0-SausageGit"; +const KEX: &[u8] = b"curve25519-sha256"; +const HOST: &[u8] = b"ssh-ed25519"; +const CIPHER: &[u8] = b"aes128-ctr"; +const MAC: &[u8] = b"hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256"; + +pub fn banner(stream: &mut S) -> io::Result> { + stream.write_all(VERSION)?; + stream.write_all(b"\r\n")?; + stream.flush()?; + // RFC4253 permits pre-banner lines, bounded here to 8KiB. + let mut line = Vec::new(); + for _ in 0..8192 { + let mut b = [0]; + stream.read_exact(&mut b)?; + if b[0] == b'\n' { + if line.last() == Some(&b'\r') { + line.pop(); + } + if line.starts_with(b"SSH-") { + if !line.starts_with(b"SSH-2.0-") || line.len() > 253 { + return Err(fail("unsupported server version")); + } + return Ok(line); + } + line.clear(); + } else { + line.push(b[0]); + if line.len() > 1024 { + return Err(fail("server banner too long")); + } + } + } + Err(fail("server banner missing")) +} +fn init() -> io::Result> { + let mut p = vec![20]; + let mut cookie = [0; 16]; + random(&mut cookie)?; + p.extend(cookie); + for list in [ + KEX, HOST, CIPHER, CIPHER, MAC, MAC, b"none", b"none", b"", b"", + ] { + string(&mut p, list); + } + p.push(0); + uint(&mut p, 0); + Ok(p) +} +fn contains(list: &[u8], name: &[u8]) -> bool { + list.split(|b| *b == b',').any(|n| n == name) +} +fn select<'a>(kind: &str, ours: &'a [u8], theirs: &[u8]) -> io::Result<&'a [u8]> { + ours.split(|b| *b == b',') + .find(|name| contains(theirs, name)) + .ok_or_else(|| { + fail(&format!( + "no matching {kind}; client offered {:?}; server offered {:?}", + String::from_utf8_lossy(ours), + String::from_utf8_lossy(theirs) + )) + }) +} +#[derive(Debug)] +struct Negotiated { + send: MacAlgorithm, + recv: MacAlgorithm, + wrong_guess: bool, +} +fn negotiate(server: &[u8]) -> io::Result { + let mut d = Decoder(server); + if d.byte()? != 20 { + return Err(fail("expected KEXINIT")); + } + d.take(16)?; + let kex = d.string()?; + let host = d.string()?; + select("kex", KEX, kex)?; + select("host-key", HOST, host)?; + select("cipher client-to-server", CIPHER, d.string()?)?; + select("cipher server-to-client", CIPHER, d.string()?)?; + let send = MacAlgorithm::from_name(select("MAC client-to-server", MAC, d.string()?)?)?; + let recv = MacAlgorithm::from_name(select("MAC server-to-client", MAC, d.string()?)?)?; + select("compression client-to-server", b"none", d.string()?)?; + select("compression server-to-client", b"none", d.string()?)?; + d.string()?; + d.string()?; + let follows = d.byte()? != 0; + d.uint()?; + d.end()?; + Ok(Negotiated { + send, + recv, + wrong_guess: follows + && (kex.split(|b| *b == b',').next() != Some(KEX) + || host.split(|b| *b == b',').next() != Some(HOST)), + }) +} +/// RFC8731 §3.1 reinterprets the raw X25519 output as network-order unsigned K. +pub fn mpint(shared: &[u8; 32]) -> Vec { + let mut value: Vec<_> = shared.iter().copied().skip_while(|b| *b == 0).collect(); + if value.first().is_some_and(|b| b & 128 != 0) { + value.insert(0, 0); + } + let mut out = Vec::new(); + string(&mut out, &value); + out +} +pub fn derive(k: &[u8], hash: &[u8], letter: u8, session: &[u8]) -> Vec { + let mut h = Sha256::new(); + h.update(k); + h.update(hash); + h.update([letter]); + h.update(session); + h.finalize().to_vec() +} +/// RFC4253 §7.2 expansion: SHA-512 MAC needs 64 bytes even with SHA-256 KEX. +pub fn derive_len(k: &[u8], hash: &[u8], letter: u8, session: &[u8], len: usize) -> Vec { + let mut key = derive(k, hash, letter, session); + while key.len() < len { + let mut h = Sha256::new(); + h.update(k); + h.update(hash); + h.update(&key); + key.extend_from_slice(&h.finalize()); + } + key.truncate(len); + key +} +/// Authenticate a key exchange and switch protection in each direction at NEWKEYS. +pub fn exchange( + packets: &mut Packets, + server_version: &[u8], + session: &mut Vec, + server_init: Option>, + mut verify: impl FnMut(&[u8]) -> io::Result<()>, +) -> io::Result<()> { + let ours = init()?; + packets.write(&ours)?; + let theirs = match server_init { + Some(p) => p, + None => packets.meaningful()?, + }; + let negotiated = negotiate(&theirs)?; + let mut secret = [0; 32]; + random(&mut secret)?; + let secret = StaticSecret::from(secret); + let public = PublicKey::from(&secret); + packets.write(&message(30, &[public.as_bytes()]))?; + if negotiated.wrong_guess { + packets.meaningful()?; + } + let reply = packets.meaningful()?; + let mut d = Decoder(&reply); + if d.byte()? != 31 { + return Err(fail("expected curve25519 reply")); + } + let host = d.string()?; + let remote = d.string()?; + let signature = d.string()?; + d.end()?; + let remote: [u8; 32] = remote + .try_into() + .map_err(|_| fail("invalid curve25519 public key"))?; + let shared = secret.diffie_hellman(&PublicKey::from(remote)); + if !shared.was_contributory() { + return Err(fail("invalid all-zero shared secret")); + } + let k = mpint(shared.as_bytes()); + let mut transcript = Vec::new(); + for item in [ + VERSION, + server_version, + &ours, + &theirs, + host, + public.as_bytes(), + &remote, + ] { + string(&mut transcript, item); + } + transcript.extend_from_slice(&k); + let hash = Sha256::digest(&transcript); + let key = ssh_key::PublicKey::from_bytes(host).map_err(|_| fail("invalid host public key"))?; + if key.algorithm() != ssh_key::Algorithm::Ed25519 { + return Err(fail("unsupported host key")); + } + let signature = + ssh_key::Signature::try_from(signature).map_err(|_| fail("invalid host signature"))?; + Verifier::verify(&key, &hash, &signature) + .map_err(|_| fail("host key exchange signature mismatch"))?; + verify(host)?; + if session.is_empty() { + session.extend_from_slice(&hash); + } + let send = Protection::with_mac( + &derive(&k, &hash, b'C', session)[..16], + &derive(&k, &hash, b'A', session)[..16], + derive_len(&k, &hash, b'E', session, negotiated.send.key_len()), + negotiated.send, + )?; + let recv = Protection::with_mac( + &derive(&k, &hash, b'D', session)[..16], + &derive(&k, &hash, b'B', session)[..16], + derive_len(&k, &hash, b'F', session, negotiated.recv.key_len()), + negotiated.recv, + )?; + packets.write(&[21])?; + packets.send = Some(send); + if packets.meaningful()? != [21] { + return Err(fail("expected NEWKEYS")); + } + packets.recv = Some(recv); + Ok(()) +} diff --git a/userland/ssh-client/src/ssh_negotiation_tests.inc b/userland/ssh-client/src/ssh_negotiation_tests.inc new file mode 100644 index 0000000..7c9d75b --- /dev/null +++ b/userland/ssh-client/src/ssh_negotiation_tests.inc @@ -0,0 +1,48 @@ +#[cfg(test)] +mod negotiation_tests { + use super::*; + fn server(lists: &[&[u8];8], follows: bool) -> Vec { + let mut p = vec![20]; p.extend([7;16]); + for list in lists { string(&mut p, list); } + string(&mut p, b""); string(&mut p, b""); p.push(u8::from(follows)); uint(&mut p, 0); p + } + const NIXOS: &[u8] = b"hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com"; + #[test] + fn nixos_selection_directionality_and_wrong_guess() { + let mut lists = [KEX, HOST, CIPHER, CIPHER, NIXOS, NIXOS, b"none", b"none"]; + let n = negotiate(&server(&lists, false)).unwrap(); + // Client order wins over server preference for SHA-512. + assert_eq!(n.send, MacAlgorithm::Sha256Etm); assert_eq!(n.recv, n.send); assert!(!n.wrong_guess); + lists[4] = b"hmac-sha2-512-etm@openssh.com"; lists[5] = b"hmac-sha2-256"; + let n = negotiate(&server(&lists, true)).unwrap(); + assert_eq!(n.send, MacAlgorithm::Sha512Etm); assert_eq!(n.recv, MacAlgorithm::Sha256); assert!(!n.wrong_guess); + lists[0] = b"unknown-kex,curve25519-sha256"; + assert!(negotiate(&server(&lists, true)).unwrap().wrong_guess); + lists[0] = KEX; lists[1] = b"unknown-host,ssh-ed25519"; + assert!(negotiate(&server(&lists, true)).unwrap().wrong_guess); + } + #[test] + fn mismatch_names_both_offers_and_direction() { + let lists = [KEX, HOST, CIPHER, CIPHER, MAC, MAC, b"none", b"none"]; + let categories = ["kex", "host-key", "cipher client-to-server", "cipher server-to-client", + "MAC client-to-server", "MAC server-to-client", "compression client-to-server", "compression server-to-client"]; + for (i, category) in categories.into_iter().enumerate() { + let mut bad = lists; bad[i] = b"unsupported-one,unsupported-two"; + let error = negotiate(&server(&bad, false)).unwrap_err().to_string(); + assert!(error.contains(category), "{error}"); + assert!(error.contains(&format!("client offered {:?}", String::from_utf8_lossy(lists[i]))), "{error}"); + assert!(error.contains("server offered \"unsupported-one,unsupported-two\""), "{error}"); + } + let valid = server(&lists, false); + for end in 0..valid.len() { assert!(negotiate(&valid[..end]).is_err()); } + } + #[test] + fn sha512_mac_key_uses_rfc4253_sha256_expansion() { + // Independent OpenSSL/Node fixture, including an original session id != H. + let key = derive_len(&[0,0,0,3,1,2,3], &[6;32], b'E', &[7;32], 64); + let hex: String = key.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hex, "2a9e79189e2902184e372b346241650ef74c7c339fd779556f7c09c1c0cc646e1c94097a9ede9b16f621e18b672f1a126c6530502a27032c85018b243cbde381"); + assert_eq!(&key[..32], derive(&[0,0,0,3,1,2,3], &[6;32], b'E', &[7;32])); + assert_eq!(derive_len(&[0,0,0,3,1,2,3], &[6;32], b'E', &[7;32], 16), key[..16]); + } +} diff --git a/userland/ssh-client/src/ssh_protection_tests.inc b/userland/ssh-client/src/ssh_protection_tests.inc new file mode 100644 index 0000000..3833713 --- /dev/null +++ b/userland/ssh-client/src/ssh_protection_tests.inc @@ -0,0 +1,89 @@ +#[cfg(test)] +mod protection_tests { + use super::*; + use std::io::Cursor; + + struct Memory { input: Cursor>, output: Vec } + impl Read for Memory { fn read(&mut self, b: &mut [u8]) -> io::Result { self.input.read(b) } } + impl Write for Memory { + fn write(&mut self, b: &[u8]) -> io::Result { self.output.extend_from_slice(b); Ok(b.len()) } + fn flush(&mut self) -> io::Result<()> { Ok(()) } + } + fn protection(algorithm: MacAlgorithm) -> Protection { + Protection::with_mac(&[3;16], &[4;16], vec![5;algorithm.key_len()], algorithm).unwrap() + } + fn reader(bytes: Vec, algorithm: MacAlgorithm) -> Packets { + let mut packets = Packets::new(Memory { input: Cursor::new(bytes), output: vec![] }); + packets.recv = Some(protection(algorithm)); packets + } + fn hex(s: &str) -> Vec { + s.as_bytes().chunks_exact(2).map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap()).collect() + } + #[test] + fn etm_openssl_fixtures_and_authenticate_before_decrypt() { + // Generated independently using Node/OpenSSL; generator and output recorded in + // /docs/symphony/artifacts/SAU-415/vectors.mjs (no shared packet/KDF code). + for (algorithm, vector) in [ + (MacAlgorithm::Sha256Etm, "00000010e0682f200ab506a20a4da74db3b6ff408f978288292d6b1f9a194fcf108ee3d8dd20edd265854e0683c7545d89a98533000000208c23fc658200527682c8cef58c2867088a31921a7419b34e9d807ee551118a9d96ec28c65f3d7a23da2197da11b30ea7f2625d4179ece3c724ccbe8f78da4463"), + (MacAlgorithm::Sha512Etm, "00000010e0682f200ab506a20a4da74db3b6ff406d1fd80c98bae4318b01500b79fe77b71ec0ab3ca69841dd51116b6d3277d7e51405c3c76ac95b4964988851f12e55d9430c8e227931fffdd5b95a98859586e5000000208c23fc658200527682c8cef58c2867088a31921a7419b34e9d807ee551118a9da235ac50894cf397f912da5296a848b5d240de3e78fadf4ac278430a377ee6448ddaa491aa9007041e83a6fcd9d77aec0855f0cfff765924d1e060796ca2b09c"), + ] { + let bytes = hex(vector); + let mut packets = reader(bytes.clone(), algorithm); + assert_eq!(packets.read().unwrap(), [5]); + assert_eq!(packets.read().unwrap(), b"independent fixture"); + for offset in [4, 19, 20] { // encrypted padding length, ciphertext, tag + let mut bad = bytes.clone(); bad[offset] ^= 1; + let mut packets = reader(bad, algorithm); + assert!(packets.read().unwrap_err().to_string().contains("MAC mismatch")); + // Failed authentication did not consume cipher state or sequence number. + packets.stream.input = Cursor::new(bytes.clone()); + assert_eq!(packets.read().unwrap(), [5]); + } + let first_len = 4 + 16 + algorithm.key_len(); + assert!(reader(bytes[first_len..].to_vec(), algorithm).read().unwrap_err().to_string().contains("MAC mismatch")); + for end in 0..first_len { assert!(reader(bytes[..end].to_vec(), algorithm).read().is_err()); } + for len in [0u32, 5, 17, 262145, u32::MAX] { + let mut bad = bytes.clone(); bad[..4].copy_from_slice(&len.to_be_bytes()); + assert!(reader(bad, algorithm).read().unwrap_err().to_string().contains("invalid packet length")); + } + let mut bad = bytes.clone(); bad[..4].copy_from_slice(&32u32.to_be_bytes()); + assert!(reader(bad, algorithm).read().unwrap_err().to_string().contains("MAC mismatch")); + } + } + #[test] + fn all_mac_packet_boundaries_and_independent_outbound_check() { + for algorithm in [MacAlgorithm::Sha256, MacAlgorithm::Sha256Etm, MacAlgorithm::Sha512Etm] { + let mut packets = Packets::new(Memory { input: Cursor::new(vec![]), output: vec![] }); + packets.send = Some(protection(algorithm)); + let mut cipher = Aes::new_from_slices(&[3;16], &[4;16]).unwrap(); + let mut incoming = reader(vec![], algorithm); + for (seq, size) in [1, 7, 8, 11, 12, 15, 16, 31, 32768, 65536].into_iter().enumerate() { + let payload = vec![42;size]; packets.write(&payload).unwrap(); + let encoded = std::mem::take(&mut packets.stream.output); + incoming.stream.input = Cursor::new(encoded.clone()); + assert_eq!(incoming.read().unwrap(), payload); + // Check sender without using Protection or Packets for decryption/MAC. + let split = encoded.len() - algorithm.key_len(); + let mut plain = encoded[..split].to_vec(); + if algorithm.etm() { + assert_eq!(Decoder(&plain).uint().unwrap() as usize, split - 4); + assert_eq!((split - 4) % 16, 0); + } + let start = if algorithm.etm() { 4 } else { 0 }; + cipher.apply_keystream(&mut plain[start..]); + let signed = if algorithm.etm() { &encoded[..split] } else { &plain }; + let key = vec![5;algorithm.key_len()]; + let seq = (seq as u32).to_be_bytes(); + if algorithm == MacAlgorithm::Sha512Etm { + let mut h = as Mac>::new_from_slice(&key).unwrap(); + h.update(&seq); h.update(signed); h.verify_slice(&encoded[split..]).unwrap(); + } else { + let mut h = as Mac>::new_from_slice(&key).unwrap(); + h.update(&seq); h.update(signed); h.verify_slice(&encoded[split..]).unwrap(); + } + assert_eq!(&plain[5..5+size], payload); + assert!(plain[4] >= 4); + } + } + } +} diff --git a/userland/ssh-client/src/ssh_wire.rs b/userland/ssh-client/src/ssh_wire.rs new file mode 100644 index 0000000..5fd7c68 --- /dev/null +++ b/userland/ssh-client/src/ssh_wire.rs @@ -0,0 +1,250 @@ +//! Small SSH binary codec and RFC4253 / OpenSSH EtM packet protection. +include!("ssh_protection_tests.inc"); +use aes::cipher::{KeyIvInit, StreamCipher}; +use hmac::{Hmac, Mac}; +use rand_core::{OsRng, RngCore}; +use sha2::{Sha256, Sha512}; +use std::io::{self, Read, Write}; +pub type Aes = ctr::Ctr128BE; +pub fn fail(message: &str) -> io::Error { + io::Error::other(format!("ssh: {message}")) +} +pub fn random(bytes: &mut [u8]) -> io::Result<()> { + OsRng + .try_fill_bytes(bytes) + .map_err(|_| fail("random source unavailable")) +} +pub fn uint(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_be_bytes()); +} +pub fn string(out: &mut Vec, bytes: &[u8]) { + uint(out, bytes.len() as u32); + out.extend_from_slice(bytes); +} +pub fn message(kind: u8, strings: &[&[u8]]) -> Vec { + let mut out = vec![kind]; + for s in strings { + string(&mut out, s); + } + out +} +pub struct Decoder<'a>(pub &'a [u8]); +impl<'a> Decoder<'a> { + pub fn take(&mut self, len: usize) -> io::Result<&'a [u8]> { + if len > self.0.len() { + return Err(fail("truncated message")); + } + let (a, b) = self.0.split_at(len); + self.0 = b; + Ok(a) + } + pub fn byte(&mut self) -> io::Result { + Ok(self.take(1)?[0]) + } + pub fn uint(&mut self) -> io::Result { + let b = self.take(4)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + pub fn string(&mut self) -> io::Result<&'a [u8]> { + let len = self.uint()? as usize; + self.take(len) + } + pub fn end(&self) -> io::Result<()> { + if self.0.is_empty() { + Ok(()) + } else { + Err(fail("trailing message bytes")) + } + } +} +/// MAC key/tag sizes match OpenSSH mac.c; EtM framing follows PROTOCOL §1.5. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MacAlgorithm { + Sha256, + Sha256Etm, + Sha512Etm, +} +impl MacAlgorithm { + pub fn from_name(name: &[u8]) -> io::Result { + match name { + b"hmac-sha2-256" => Ok(Self::Sha256), + b"hmac-sha2-256-etm@openssh.com" => Ok(Self::Sha256Etm), + b"hmac-sha2-512-etm@openssh.com" => Ok(Self::Sha512Etm), + _ => Err(fail("unsupported MAC")), + } + } + pub fn key_len(self) -> usize { + if self == Self::Sha512Etm { 64 } else { 32 } + } + fn etm(self) -> bool { + self != Self::Sha256 + } +} +enum Tag { + Sha256(Hmac), + Sha512(Hmac), +} +impl Tag { + fn bytes(self) -> Vec { + match self { + Self::Sha256(h) => h.finalize().into_bytes().to_vec(), + Self::Sha512(h) => h.finalize().into_bytes().to_vec(), + } + } + fn verify(self, tag: &[u8]) -> io::Result<()> { + match self { + Self::Sha256(h) => h.verify_slice(tag), + Self::Sha512(h) => h.verify_slice(tag), + } + .map_err(|_| fail("packet MAC mismatch")) + } +} +pub struct Protection { + cipher: Aes, + mac: Vec, + algorithm: MacAlgorithm, +} +impl Protection { + /// Historical non-EtM constructor, retained for existing callers. + pub fn new(key: &[u8], iv: &[u8], mac: Vec) -> io::Result { + Self::with_mac(key, iv, mac, MacAlgorithm::Sha256) + } + pub fn with_mac( + key: &[u8], + iv: &[u8], + mac: Vec, + algorithm: MacAlgorithm, + ) -> io::Result { + if mac.len() != algorithm.key_len() { + return Err(fail("invalid MAC key size")); + } + Ok(Self { + cipher: Aes::new_from_slices(key, iv).map_err(|_| fail("invalid key size"))?, + mac, + algorithm, + }) + } + fn tag(&self, seq: u32, bytes: &[u8]) -> io::Result { + if self.algorithm == MacAlgorithm::Sha512Etm { + let mut h = as Mac>::new_from_slice(&self.mac) + .map_err(|_| fail("invalid MAC key"))?; + h.update(&seq.to_be_bytes()); + h.update(bytes); + Ok(Tag::Sha512(h)) + } else { + let mut h = as Mac>::new_from_slice(&self.mac) + .map_err(|_| fail("invalid MAC key"))?; + h.update(&seq.to_be_bytes()); + h.update(bytes); + Ok(Tag::Sha256(h)) + } + } +} +pub struct Packets { + pub stream: S, + pub send: Option, + pub recv: Option, + tx: u32, + rx: u32, +} +impl Packets { + pub fn new(stream: S) -> Self { + Self { + stream, + send: None, + recv: None, + tx: 0, + rx: 0, + } + } + pub fn write(&mut self, payload: &[u8]) -> io::Result<()> { + if payload.is_empty() || payload.len() > 65536 { + return Err(fail("outgoing packet size")); + } + let block = if self.send.is_some() { 16 } else { 8 }; + let etm = self.send.as_ref().is_some_and(|p| p.algorithm.etm()); + let mut padding = block - (payload.len() + if etm { 1 } else { 5 }) % block; + if padding < 4 { + padding += block; + } + let mut packet = Vec::with_capacity(payload.len() + padding + 5); + uint(&mut packet, (payload.len() + padding + 1) as u32); + packet.push(padding as u8); + packet.extend_from_slice(payload); + let start = packet.len(); + packet.resize(start + padding, 0); + random(&mut packet[start..])?; + // Legacy MAC authenticates plaintext; EtM authenticates length + ciphertext. + let mut tag = None; + if let Some(p) = &mut self.send { + if !etm { + tag = Some(p.tag(self.tx, &packet)?.bytes()); + } + p.cipher + .apply_keystream(&mut packet[if etm { 4 } else { 0 }..]); + if etm { + tag = Some(p.tag(self.tx, &packet)?.bytes()); + } + } + self.stream.write_all(&packet)?; + if let Some(tag) = tag { + self.stream.write_all(&tag)?; + } + self.stream.flush()?; + self.tx = self.tx.wrapping_add(1); + Ok(()) + } + pub fn read(&mut self) -> io::Result> { + let block = if self.recv.is_some() { 16 } else { 8 }; + let etm = self.recv.as_ref().is_some_and(|p| p.algorithm.etm()); + let prefix = if etm { 4 } else { block }; + let mut packet = vec![0; prefix]; + self.stream.read_exact(&mut packet)?; + if !etm { + if let Some(p) = &mut self.recv { + p.cipher.apply_keystream(&mut packet); + } + } + let len = Decoder(&packet).uint()? as usize; + if !(5..=262144).contains(&len) { + return Err(fail("invalid packet length")); + } + let aligned = len + if etm { 0 } else { 4 }; + if aligned % block != 0 || len + 4 < prefix { + return Err(fail("invalid packet length")); + } + packet.resize(len + 4, 0); + self.stream.read_exact(&mut packet[prefix..])?; + if let Some(p) = &mut self.recv { + let mut tag = vec![0; p.algorithm.key_len()]; + if etm { + self.stream.read_exact(&mut tag)?; + p.tag(self.rx, &packet)?.verify(&tag)?; + // Never advance the cipher or inspect padding before authenticating EtM. + p.cipher.apply_keystream(&mut packet[4..]); + } else { + p.cipher.apply_keystream(&mut packet[prefix..]); + self.stream.read_exact(&mut tag)?; + p.tag(self.rx, &packet)?.verify(&tag)?; + } + } + self.rx = self.rx.wrapping_add(1); + let padding = packet[4] as usize; + if padding < 4 || padding + 1 >= len { + return Err(fail("invalid packet padding")); + } + Ok(packet[5..len + 4 - padding].to_vec()) + } + pub fn meaningful(&mut self) -> io::Result> { + for _ in 0..256 { + let p = self.read()?; + match p.first() { + Some(1) => return Err(fail("server disconnected")), + Some(2 | 4) => continue, // IGNORE, DEBUG (never log remote text) + Some(_) => return Ok(p), + None => return Err(fail("empty packet")), + } + } + Err(fail("too many non-progress messages")) + } +} diff --git a/userland/ssh/Cargo.toml b/userland/ssh/Cargo.toml new file mode 100644 index 0000000..c6333f9 --- /dev/null +++ b/userland/ssh/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "ssh" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4", features = ["derive"] } +sausage-ssh = { path = "../ssh-client" } diff --git a/userland/ssh/README.md b/userland/ssh/README.md new file mode 100644 index 0000000..1ff63ad --- /dev/null +++ b/userland/ssh/README.md @@ -0,0 +1,60 @@ +# SSH in the world + +`ssh` executes one remote command without a PTY or an interactive shell. +It buffers stdin and captures stdout and stderr until the remote channel closes. +It returns the remote exit status. Connection, trust, and authentication failures return 255. + +```sh +ssh -p 2222 -l user -o StrictHostKeyChecking=accept-new host 'echo hi; exit 3' +``` + +The default host-key policy requires a matching key in `$HOME/.ssh/known_hosts`. +The client creates `.ssh` if needed. +`StrictHostKeyChecking=accept-new` appends an unknown key. +A changed or revoked key always fails. +The client supports Ed25519 keys and curve25519 key exchange, as v2 did. + +The private key stays in the host. +The client requests a public identity from `POST http://host.sausage/ssh/identity`. +It requests a signature from `POST http://host.sausage/ssh/sign`. +`SAUSAGE_HOST_API` replaces the base URL for standalone tests. +The URL or `-l` supplies the user. Otherwise, the host identity supplies it. +A host denial preserves the item and request information in stderr. + +## Tests + +Build from `userland/` after the shared startup object exists: + +```sh +CARGO_TARGET_DIR=/tmp/p1-ssh/target nix develop .. -c cargo build -p ssh -p git --release --locked +``` + +Build the native test fixture from the repository root: + +```sh +CARGO_TARGET_DIR=/tmp/p1-ssh/server-target nix develop -c cargo build --manifest-path userland/ssh/tests/server/Cargo.toml --locked +``` + +Start the fixture through a managed job or a separate terminal: + +```sh +nix shell nixpkgs#openssh -c /tmp/p1-ssh/server-target/debug/ssh-test-server /tmp/p1-ssh/fixture user +``` + +Use the current native user's login name instead of `user`. +The fixture creates `ready.json` after sshd and the signing server start. +It keeps all private keys under `fixture/host`, outside the guest preopen. +It serves the two host routes and a test-only host-key rotation route. + +```sh +nix develop -c nu userland/ssh/test.nu /tmp/p1-ssh/target/wasm32-wasip2/release/ssh.wasm /tmp/p1-ssh/fixture +``` + +The Git script needs a separate fresh fixture because the SSH script changes its host key: + +```sh +nix develop -c nu userland/git/ssh-test.nu /tmp/p1-ssh/target/wasm32-wasip2/release/git.wasm /tmp/p1-ssh/target/wasm32-wasip2/release/ssh.wasm /tmp/p1-ssh/git-fixture +``` + +Both scripts check that the components import only WASI interfaces. +They enable TCP, DNS, HTTP, and numeric exit codes through Wasmtime's standard options. diff --git a/userland/ssh/src/main.rs b/userland/ssh/src/main.rs new file mode 100644 index 0000000..8692977 --- /dev/null +++ b/userland/ssh/src/main.rs @@ -0,0 +1,54 @@ +//! One noninteractive SSH command with host-mediated authentication. +use clap::Parser; +use std::{ + io::{self, Read, Write}, + time::{Duration, Instant}, +}; +#[derive(Parser)] +struct Args { + #[arg(short = 'p', default_value_t = 22)] + port: u16, + #[arg(short = 'l')] + user: Option, + #[arg(short = 'o')] + options: Vec, + host: String, + #[arg(required = true, trailing_var_arg = true)] + command: Vec, +} +fn run(args: Args) -> io::Result { + let mut accept_new = false; + for option in args.options { + match option.as_str() { + "StrictHostKeyChecking=accept-new" => accept_new = true, + "StrictHostKeyChecking=yes" => accept_new = false, + _ => return Err(io::Error::other("unsupported SSH option")), + } + } + let (user, host) = match args.host.split_once('@') { + Some((user, host)) => (Some(user), host), + None => (args.user.as_deref(), args.host.as_str()), + }; + let session = sausage_ssh::identity::connect(host, args.port, user, accept_new)?; + let mut input = Vec::new(); + io::stdin().read_to_end(&mut input)?; + let output = session.execute_input( + args.command.join(" ").as_bytes(), + &input, + usize::MAX, + Instant::now() + Duration::from_secs(300), + )?; + io::stdout().write_all(&output.stdout)?; + io::stderr().write_all(&output.stderr)?; + Ok(output.exit_code.unwrap_or(255)) +} +fn main() { + let status = match run(Args::parse()) { + Ok(status) => status, + Err(error) => { + eprintln!("{error}"); + 255 + } + }; + std::process::exit(status.min(255) as i32); +} diff --git a/userland/ssh/test.nu b/userland/ssh/test.nu new file mode 100644 index 0000000..60586f8 --- /dev/null +++ b/userland/ssh/test.nu @@ -0,0 +1,56 @@ +#!/usr/bin/env nu +# Verify SSH streams, host trust, host denial, and standard WASI imports. +use std/assert +def wasi [component: path, root: path, args: list, input: string = ''] { + let ready = (open ($root | path join ready.json)) + $input | ^wasmtime run -S inherit-network -S allow-ip-name-lookup -S http -S cli-exit-with-code=y --dir $'($root)/guest::/' --env HOME=/home --env $'SAUSAGE_HOST_API=http://127.0.0.1:($ready.api)' $component ...$args | complete +} +def main [component: path, root: path] { + let component = ($component | path expand) + let root = ($root | path expand) + let wit = (^wasm-tools component wit $component | complete) + assert equal $wit.exit_code 0 + let imports = ($wit.stdout | lines | str trim | where {|line| $line | str starts-with 'import '}) + assert (not ($imports | is-empty)) + assert ($imports | all {|line| $line | str starts-with 'import wasi:'}) + print 'SSH component imports: PASS (WASI only)' + print ($imports | str join (char newline)) + let ready = (open ($root | path join ready.json)) + let destination = $'($ready.user)@127.0.0.1' + let args = [-p $ready.ssh $destination 'echo hi; exit 3'] + let unknown = (wasi $component $root $args) + assert equal $unknown.exit_code 255 + assert ($unknown.stderr | str contains 'Host key verification failed') + print 'Unknown host key: PASS' + let accepted = (wasi $component $root ([-o StrictHostKeyChecking=accept-new] | append $args)) + assert equal $accepted.exit_code 3 + assert equal $accepted.stdout $"hi\n" + assert (($root | path join guest/home/.ssh/known_hosts) | path exists) + print 'Accept-new records the host key, prints hi, and exits 3: PASS' + let repeated = (wasi $component $root $args) + assert equal $repeated.exit_code 3 + assert equal $repeated.stdout $"hi\n" + print 'Known host key: PASS' + let streams = (wasi $component $root [-p $ready.ssh -l $ready.user 127.0.0.1 'cat; echo error >&2; exit 7'] $"input\n") + assert equal $streams.exit_code 7 + assert equal $streams.stdout $"input\n" + assert equal $streams.stderr $"error\n" + print 'Stdin, stdout, stderr, -l user, and remote exit 7: PASS' + let default_user = (wasi $component $root [-p $ready.ssh 127.0.0.1 'echo default']) + assert equal $default_user.exit_code 0 + assert equal $default_user.stdout $"default\n" + print 'Default user from host identity: PASS' + 'deny' | save ($root | path join host/deny) + let denied = (wasi $component $root $args) + assert equal $denied.exit_code 255 + assert ($denied.stderr | str contains 'test-key') + assert ($denied.stderr | str contains 'test-request') + print 'Host API 403 names the item and request, and exits 255: PASS' + rm ($root | path join host/deny) + http post --content-type application/json $'http://127.0.0.1:($ready.api)/fixture/rotate' '{}' | ignore + let changed = (wasi $component $root ([-o StrictHostKeyChecking=accept-new] | append $args)) + assert equal $changed.exit_code 255 + assert ($changed.stderr | str contains 'REMOTE HOST IDENTIFICATION HAS CHANGED') + print 'Changed host key fails even with accept-new: PASS' + print $'Fixture: ($root)' +} diff --git a/userland/ssh/tests/server/Cargo.lock b/userland/ssh/tests/server/Cargo.lock new file mode 100644 index 0000000..654fc7d --- /dev/null +++ b/userland/ssh/tests/server/Cargo.lock @@ -0,0 +1,751 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[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", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", +] + +[[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", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[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 = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "ssh-test-server" +version = "0.1.0" +dependencies = [ + "base64", + "rand_core", + "serde_json", + "signature", + "ssh-key", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/userland/ssh/tests/server/Cargo.toml b/userland/ssh/tests/server/Cargo.toml new file mode 100644 index 0000000..ca7251a --- /dev/null +++ b/userland/ssh/tests/server/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ssh-test-server" +version = "0.1.0" +edition = "2024" + +[workspace] + +[dependencies] +ssh-key = { version = "0.6.7", default-features = false, features = ["std", "ed25519", "getrandom"] } +rand_core = { version = "0.6", features = ["getrandom"] } +signature = "2" +base64 = "0.22" +serde_json = "1" diff --git a/userland/ssh/tests/server/src/main.rs b/userland/ssh/tests/server/src/main.rs new file mode 100644 index 0000000..54a725d --- /dev/null +++ b/userland/ssh/tests/server/src/main.rs @@ -0,0 +1,155 @@ +//! Native localhost sshd fixture and host-only signing oracle for WASI tests. +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde_json::{Value, json}; +use signature::Signer; +use ssh_key::{Algorithm, LineEnding, PrivateKey}; +use std::{ + fs, + io::{BufRead, BufReader, Read, Write}, + net::{TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; +fn key(path: &Path) -> PrivateKey { + let key = PrivateKey::random(&mut rand_core::OsRng, Algorithm::Ed25519).unwrap(); + key.write_openssh_file(path, LineEnding::LF).unwrap(); + key +} +struct Daemon { + child: Child, + config: PathBuf, + binary: PathBuf, + host_key: PathBuf, + log: PathBuf, + port: u16, +} +impl Daemon { + fn start(binary: &Path, config: &Path, log: &Path, port: u16) -> Child { + let child = Command::new(binary) + .args(["-D", "-e", "-f"]) + .arg(config) + .stdout(Stdio::null()) + .stderr( + fs::OpenOptions::new() + .create(true) + .append(true) + .open(log) + .unwrap(), + ) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while TcpStream::connect(("127.0.0.1", port)).is_err() { + assert!( + Instant::now() < deadline, + "sshd failed to start; inspect {}", + log.display() + ); + std::thread::yield_now(); + } + child + } + fn rotate(&mut self) { + self.child.kill().unwrap(); + self.child.wait().unwrap(); + key(&self.host_key); + self.child = Self::start(&self.binary, &self.config, &self.log, self.port); + } +} +impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} +fn main() { + let root = PathBuf::from(std::env::args().nth(1).expect("fixture root")); + let user = std::env::args().nth(2).expect("SSH user"); + let host = root.join("host"); + fs::create_dir_all(&host).unwrap(); + fs::create_dir_all(root.join("guest/home")).unwrap(); + let client = key(&host.join("client_key")); + fs::write( + host.join("authorized_keys"), + client.public_key().to_openssh().unwrap(), + ) + .unwrap(); + let host_key = host.join("server_key"); + key(&host_key); + let socket = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = socket.local_addr().unwrap().port(); + drop(socket); + let binary = std::env::split_paths(&std::env::var_os("PATH").unwrap()) + .map(|p| p.join("sshd")) + .find(|p| p.is_file()) + .unwrap() + .canonicalize() + .unwrap(); + let config = host.join("sshd_config"); + fs::write(&config, format!("Port {port}\nListenAddress 127.0.0.1\nHostKey {}\nPidFile {}\nAuthorizedKeysFile {}\nStrictModes no\nUsePAM no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nAllowUsers {user}\nLogLevel VERBOSE\n", host_key.display(), host.join("sshd.pid").display(), host.join("authorized_keys").display())).unwrap(); + let log = host.join("sshd.log"); + let child = Daemon::start(&binary, &config, &log, port); + let mut daemon = Daemon { + child, + config, + binary, + host_key, + log, + port, + }; + let api = TcpListener::bind("127.0.0.1:0").unwrap(); + fs::write( + root.join("ready.json"), + json!({"ssh":port,"api":api.local_addr().unwrap().port(),"user":user}).to_string(), + ) + .unwrap(); + for connection in api.incoming() { + let mut stream = connection.unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut first = String::new(); + reader.read_line(&mut first).unwrap(); + let route = first.split_whitespace().nth(1).unwrap_or(""); + let mut length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + length = value.trim().parse().unwrap(); + } + } + } + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).unwrap(); + let request: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + let (status, body) = match route { + "/fixture/rotate" => { + daemon.rotate(); + (200, json!({"ok":true})) + } + "/ssh/identity" | "/ssh/sign" if host.join("deny").exists() => ( + 403, + json!({"item":"test-key","request":"test-request","message":"approval required"}), + ), + "/ssh/identity" => ( + 200, + json!({"item":"test-key","public_key":STANDARD.encode(client.public_key().to_bytes().unwrap()),"user":user}), + ), + "/ssh/sign" if request["item"] == "test-key" && request["host"] == "127.0.0.1" => { + let bytes = STANDARD + .decode(request["payload"].as_str().unwrap()) + .unwrap(); + let signature: ssh_key::Signature = client.try_sign(&bytes).unwrap(); + let signature: Vec = signature.try_into().unwrap(); + (200, json!({"signature":STANDARD.encode(signature)})) + } + _ => (400, json!({"error":"invalid request"})), + }; + let body = body.to_string(); + write!(stream, "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + } +}