diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffb60190..0fae49bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: - run: cargo fmt --all --check - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace + - name: Generated TypeScript bindings are up to date + run: git diff --exit-code -- app/src/lib/protocol.gen.ts rust-ubuntu: name: Rust core + store (Ubuntu) diff --git a/CLAUDE.md b/CLAUDE.md index 5c21d245..dbf93c8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,8 +59,12 @@ Em dev, use `BOTLOFT_HOME=.dev\home` para não tocar na instalação real em `%L - O inbox do Claude Code no Windows é um **named pipe** criado pelo próprio Claude Code. O daemon é cliente. A primeira linha tem que ser o auth com `CLAUDE_CODE_MESSAGING_TOKEN`, senão a conexão é descartada. Abra o pipe só com a mensagem pronta. - Hooks usam **exec form** (`"args": [...]`) chamando `botloftd.exe hook `. Nada de hook em bash ou PowerShell. O subcomando sai sempre com 0 e nunca escreve no stdout. -- `claude.cmd` (npm) precisa de `cmd.exe /d /s /c`; `claude.exe` (instalador nativo) roda direto. -- ConPTY não tem fd para `poll`: leitura da PTY em thread dedicada + canal. +- Só o `claude.exe` nativo roda. O `claude.cmd` do npm é recusado: passar por `cmd.exe` estraga o quoting na PTY. +- ConPTY não tem fd para `poll`: leitura da PTY em thread dedicada + canal. Ao fim do processo, feche o pseudoconsole, senão a leitura nunca vê EOF. +- O ConPTY do `portable-pty` (com `PSEUDOCONSOLE_INHERIT_CURSOR`) manda `ESC[6n` ao iniciar e trava o filho até a resposta. O daemon responde `ESC[1;1R` à primeira consulta. +- O bot recebe o ambiente padrão do usuário (`CreateEnvironmentBlock`), nunca o do daemon: em dev o daemon herda variáveis da sessão do Claude Code (`CLAUDE_CODE_MESSAGING_SOCKET`, `ANTHROPIC_BASE_URL`...). +- PATH real pode ter aspas soltas. Separe só por `;`; `std::env::split_paths` trata aspas como agrupamento e engole o resto. +- Numa sessão interativa, os hooks só rodam depois de aceitar a confiança da pasta. Teste manual de bot novo começa pelo diálogo de confiança no terminal. - Todo processo de bot entra num Job Object com kill-on-close. - Permissão de arquivo é ACL (SID do usuário atual), não modo Unix. - Estado em `%LOCALAPPDATA%`, nunca `%APPDATA%` (roaming). diff --git a/Cargo.lock b/Cargo.lock index 9a638764..25c0e45f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -82,6 +132,61 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1 0.10.7", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -139,6 +244,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -159,17 +273,53 @@ dependencies = [ [[package]] name = "botloft-core" version = "0.1.0" +dependencies = [ + "deunicode", + "serde", + "serde_json", + "thiserror 2.0.21", + "ts-rs", + "ulid", +] [[package]] name = "botloft-store" version = "0.1.0" +dependencies = [ + "botloft-core", + "rusqlite", + "tempfile", + "thiserror 2.0.21", +] [[package]] name = "botloftd" version = "0.1.0" dependencies = [ + "anyhow", + "axum", + "base64 0.23.1", "botloft-core", "botloft-store", + "bytes", + "clap", + "dirs", + "futures-util", + "getrandom 0.4.3", + "hex", + "portable-pty", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.21", + "tokio", + "tokio-tungstenite 0.30.0", + "toml 1.1.6+spec-1.1.0", + "tracing", + "tracing-appender", + "tracing-subscriber", + "windows", ] [[package]] @@ -340,6 +490,23 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -352,6 +519,52 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.8" @@ -362,6 +575,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.2" @@ -421,6 +640,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -455,6 +683,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.37.0" @@ -518,6 +755,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.12" @@ -590,14 +833,31 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -682,6 +942,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -729,7 +995,7 @@ dependencies = [ "rustc_version", "toml 1.1.6+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -755,6 +1021,28 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -780,6 +1068,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1046,6 +1345,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1202,11 +1502,32 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -1275,6 +1596,21 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.1" @@ -1288,6 +1624,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1501,6 +1838,12 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -1671,6 +2014,12 @@ dependencies = [ "serde", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libappindicator" version = "0.9.0" @@ -1729,6 +2078,23 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -1761,6 +2127,21 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" @@ -1871,10 +2252,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] -name = "num-conv" -version = "0.2.2" +name = "nix" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -2109,6 +2511,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2288,6 +2696,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -2303,6 +2732,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2401,6 +2839,52 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2509,6 +2993,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2524,12 +3033,31 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2691,6 +3219,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -2720,6 +3259,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.24.0" @@ -2753,6 +3304,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -2784,6 +3346,28 @@ dependencies = [ "stable_deref_trait", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2791,16 +3375,62 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2883,6 +3513,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2930,6 +3572,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -3141,7 +3789,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.119", "tauri-utils", "thiserror 2.0.21", @@ -3265,6 +3913,19 @@ dependencies = [ "toml 1.1.6+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -3274,6 +3935,15 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3314,6 +3984,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.55" @@ -3370,10 +4049,47 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.30.0", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -3496,6 +4212,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3534,10 +4251,36 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.21", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3545,6 +4288,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -3575,6 +4348,60 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror 2.0.21", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror 2.0.21", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.3", + "sha1 0.11.0", + "thiserror 2.0.21", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3587,6 +4414,16 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ulid" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947dde63b6d514cc5e044edad4e0ca7261afd1099d16c83d942cb2b2f348689c" +dependencies = [ + "rand 0.10.3", + "web-time", +] + [[package]] name = "unicode-ident" version = "1.0.26" @@ -3630,6 +4467,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.26.1" @@ -3642,6 +4485,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4219,6 +5074,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -4271,7 +5135,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.21", @@ -4329,6 +5193,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts new file mode 100644 index 00000000..f8360b53 --- /dev/null +++ b/app/src/lib/protocol.gen.ts @@ -0,0 +1,227 @@ +// Generated by `cargo test -p botloft-core export_bindings`. Do not edit by hand. +// Source: crates/botloft-core/src/protocol. + +export const PROTOCOL_VERSION = 1; + +/** + * Identifies a crew. + */ +export type CrewId = string; + +/** + * Identifies a bot. + */ +export type BotId = string; + +/** + * Identifies a message. + */ +export type MessageId = string; + +/** + * Identifies a delivery attempt record. + */ +export type DeliveryId = string; + +/** + * Identifies a task between bots. + */ +export type TaskId = string; + +/** + * A group of bots that can message each other and share a folder. + */ +export type Crew = { id: CrewId, name: string, +/** + * Folder name under the workspaces root. Set at creation, never changes. + */ +slug: string, paused: boolean, +/** + * Unix time in milliseconds. + */ +createdAt: number, +/** + * Unix time in milliseconds; `null` while the crew is active. + */ +archivedAt: number | null, }; + +/** + * Lifecycle state of a bot (spec 7.1). + */ +export type BotState = "offline" | "launching" | "idle" | "busy" | "needs_approval" | "rate_limited" | "auth_error" | "backoff" | "archived"; + +/** + * A persistent Claude Code session with a name, a role and instructions. + */ +export type Bot = { id: BotId, crewId: CrewId, name: string, +/** + * Address other bots use (`send_message(to: "")`). Derived from + * the name and unique among the crew's active bots. + */ +handle: string, +/** + * Folder name inside the crew folder. Set at creation, never changes. + */ +slug: string, role: string, instructions: string, +/** + * Avatar color, `#RRGGBB`. + */ +color: string, paused: boolean, state: BotState, +/** + * Current process generation; `null` if the bot has not started since + * the daemon did. Changes on every (re)start (spec 8). + */ +generation: number | null, +/** + * Absolute path of the bot's workspace folder. + */ +workspace: string, +/** + * Unix time in milliseconds. + */ +createdAt: number, +/** + * Unix time in milliseconds; `null` while the bot is active. + */ +archivedAt: number | null, }; + +/** + * Identifies the connecting app in `session.hello`. + */ +export type ClientInfo = { name: string, version: string, }; + +/** + * First request of every connection. + */ +export type HelloParams = { +/** + * Owner token from `secrets\owner.token`. + */ +token: string, client: ClientInfo, protocol: number, }; + +export type HelloResult = { daemonVersion: string, protocol: number, }; + +export type SystemStatus = { daemonVersion: string, protocol: number, uptimeMs: number, +/** + * Version reported by `claude --version`; `null` until the runtime probe runs. + */ +claudeVersion: string | null, +/** + * Why bots cannot start (Claude Code missing or too old); `null` when fine. + */ +runtimeError: string | null, }; + +export type CrewsCreateParams = { name: string, }; + +export type CrewsRenameParams = { crewId: CrewId, name: string, }; + +export type CrewsSetPausedParams = { crewId: CrewId, paused: boolean, }; + +export type CrewIdParams = { crewId: CrewId, }; + +export type BotsListParams = { +/** + * Only bots of this crew; every active bot when absent. + */ +crewId?: CrewId, }; + +export type BotsCreateParams = { crewId: CrewId, name: string, role: string, instructions: string, +/** + * Avatar color `#RRGGBB`; the next palette color when absent. + */ +color?: string, }; + +/** + * Fields left out stay unchanged. + */ +export type BotsUpdateParams = { botId: BotId, name?: string, role?: string, instructions?: string, color?: string, }; + +export type BotsSetPausedParams = { botId: BotId, paused: boolean, }; + +export type BotIdParams = { botId: BotId, }; + +export type BotsRestartParams = { botId: BotId, +/** + * Start a new conversation instead of resuming the last one. + */ +fresh?: boolean, }; + +/** + * Params of the `bot.state` notification. + */ +export type BotStateChanged = { botId: BotId, state: BotState, generation: number | null, }; + +/** + * Starts streaming a bot's terminal. With the `generation` and `offset` the + * client last saw, only the missing bytes are replayed. + */ +export type TerminalAttachParams = { botId: BotId, generation?: number, offset?: number, }; + +export type TerminalAttachResult = { generation: number, +/** + * Offset of the first byte the following `terminal.data` carries. + */ +offset: number, +/** + * True when the client must clear its screen before writing the replay. + */ +reset: boolean, }; + +export type TerminalWriteParams = { botId: BotId, +/** + * Raw bytes, base64. + */ +data: string, }; + +export type TerminalResizeParams = { botId: BotId, cols: number, rows: number, }; + +/** + * Terminal output. `offset` counts bytes since the start of `generation`. + */ +export type TerminalData = { botId: BotId, generation: number, offset: number, +/** + * Raw bytes, base64. May end in the middle of a UTF-8 sequence. + */ +data: string, }; + +/** Params and result of every request method. */ +export interface RpcMethods { + "session.hello": { params: HelloParams; result: HelloResult }; + "system.status": { params: undefined; result: SystemStatus }; + "crews.list": { params: undefined; result: Array }; + "crews.create": { params: CrewsCreateParams; result: Crew }; + "crews.rename": { params: CrewsRenameParams; result: Crew }; + "crews.setPaused": { params: CrewsSetPausedParams; result: Crew }; + "crews.archive": { params: CrewIdParams; result: Crew }; + "bots.list": { params: BotsListParams; result: Array }; + "bots.create": { params: BotsCreateParams; result: Bot }; + "bots.update": { params: BotsUpdateParams; result: Bot }; + "bots.setPaused": { params: BotsSetPausedParams; result: Bot }; + "bots.archive": { params: BotIdParams; result: Bot }; + "bots.restart": { params: BotsRestartParams; result: Bot }; + "terminal.attach": { params: TerminalAttachParams; result: TerminalAttachResult }; + "terminal.detach": { params: BotIdParams; result: null }; + "terminal.write": { params: TerminalWriteParams; result: null }; + "terminal.resize": { params: TerminalResizeParams; result: null }; +} + +/** Params of every server notification. */ +export interface RpcNotifications { + "crew.changed": Crew; + "bot.changed": Bot; + "bot.state": BotStateChanged; + "terminal.data": TerminalData; +} + +export const RpcErrorCode = { + parseError: -32700, + invalidRequest: -32600, + methodNotFound: -32601, + invalidParams: -32602, + internalError: -32603, + notAuthenticated: -32001, + notFound: -32002, + conflict: -32003, + validation: -32004, + runtimeUnavailable: -32005, +} as const; diff --git a/crates/botloft-core/Cargo.toml b/crates/botloft-core/Cargo.toml index 8959bb68..32e29129 100644 --- a/crates/botloft-core/Cargo.toml +++ b/crates/botloft-core/Cargo.toml @@ -8,6 +8,14 @@ repository.workspace = true authors.workspace = true [dependencies] +deunicode = "1.6.2" +serde = { version = "1.0.229", features = ["derive"] } +thiserror = "2.0.21" +ulid = "3.0.0" [lints] workspace = true + +[dev-dependencies] +serde_json = "1.0.151" +ts-rs = "12.0.1" diff --git a/crates/botloft-core/src/avatar.rs b/crates/botloft-core/src/avatar.rs new file mode 100644 index 00000000..59a63176 --- /dev/null +++ b/crates/botloft-core/src/avatar.rs @@ -0,0 +1,45 @@ +//! Avatar colors. Every bot is drawn as the Botloft mascot in its own color +//! (spec 15.3); the color identifies the bot and never encodes its state. + +/// Default palette, assigned in order as bots join a crew. +pub const PALETTE: [&str; 8] = [ + "#FF7A59", "#5EC8FF", "#A48BFF", "#9BE564", "#FFC857", "#FF7EB6", "#3DD9C1", "#E8D5B0", +]; + +/// Palette color for the `index`-th bot of a crew, wrapping around. +pub fn palette_color(index: usize) -> &'static str { + PALETTE[index % PALETTE.len()] +} + +/// Accepts `#RRGGBB` (any case) and returns it uppercased. +pub fn normalize_color(input: &str) -> Option { + let hex = input.trim().strip_prefix('#')?; + let valid = hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()); + valid.then(|| format!("#{}", hex.to_ascii_uppercase())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn palette_wraps_around() { + assert_eq!(palette_color(0), PALETTE[0]); + assert_eq!(palette_color(PALETTE.len() + 1), PALETTE[1]); + } + + #[test] + fn palette_colors_are_valid() { + for color in PALETTE { + assert_eq!(normalize_color(color).as_deref(), Some(color)); + } + } + + #[test] + fn normalizes_or_rejects_colors() { + assert_eq!(normalize_color("#a48bff").as_deref(), Some("#A48BFF")); + assert_eq!(normalize_color("a48bff"), None); + assert_eq!(normalize_color("#abc"), None); + assert_eq!(normalize_color("#GGGGGG"), None); + } +} diff --git a/crates/botloft-core/src/ids.rs b/crates/botloft-core/src/ids.rs new file mode 100644 index 00000000..c1891790 --- /dev/null +++ b/crates/botloft-core/src/ids.rs @@ -0,0 +1,138 @@ +//! Prefixed ULID identifiers such as `bot_01J9Z3K8...`. The prefix makes IDs +//! readable in logs; the ULID keeps them unique and ordered by creation time. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use ulid::Ulid; + +/// A string that is not a valid identifier of the expected kind. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("invalid {kind} id: expected `{prefix}` followed by a ULID")] +pub struct InvalidId { + kind: &'static str, + prefix: &'static str, +} + +macro_rules! prefixed_id { + ($(#[$doc:meta])* $name:ident, $kind:literal, $prefix:literal) => { + $(#[$doc])* + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] + #[cfg_attr(test, derive(ts_rs::TS))] + pub struct $name(String); + + impl $name { + pub const PREFIX: &'static str = $prefix; + + /// Creates a new identifier ordered after every earlier one. + pub fn generate() -> Self { + Self(format!("{}{}", $prefix, Ulid::generate())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl FromStr for $name { + type Err = InvalidId; + + fn from_str(s: &str) -> Result { + let invalid = InvalidId { kind: $kind, prefix: $prefix }; + let ulid = s.strip_prefix($prefix).ok_or(invalid.clone())?; + let ulid = Ulid::from_string(ulid).map_err(|_| invalid)?; + // Re-encode so lowercase input maps to the canonical form. + Ok(Self(format!("{}{}", $prefix, ulid))) + } + } + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result { + let raw = String::deserialize(deserializer)?; + raw.parse().map_err(serde::de::Error::custom) + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + }; +} + +prefixed_id!( + /// Identifies a crew. + CrewId, "crew", "crw_" +); +prefixed_id!( + /// Identifies a bot. + BotId, "bot", "bot_" +); +prefixed_id!( + /// Identifies a message. + MessageId, "message", "msg_" +); +prefixed_id!( + /// Identifies a delivery attempt record. + DeliveryId, "delivery", "dlv_" +); +prefixed_id!( + /// Identifies a task between bots. + TaskId, "task", "tsk_" +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_ids_carry_their_prefix_and_parse_back() { + let id = BotId::generate(); + assert!(id.as_str().starts_with("bot_")); + assert_eq!(id.as_str().len(), 4 + 26); + assert_eq!(id.as_str().parse::(), Ok(id)); + } + + #[test] + fn parsing_rejects_wrong_prefix_or_garbage() { + let crew = CrewId::generate(); + assert!(crew.as_str().parse::().is_err()); + assert!("bot_not-a-ulid".parse::().is_err()); + assert!("".parse::().is_err()); + } + + #[test] + fn lowercase_input_is_normalized() { + let id = MessageId::generate(); + let lower = format!("msg_{}", id.as_str()["msg_".len()..].to_lowercase()); + assert_eq!(lower.parse::(), Ok(id)); + } + + #[test] + fn ids_are_ordered_by_creation() { + let first = DeliveryId::generate(); + std::thread::sleep(std::time::Duration::from_millis(2)); + let second = DeliveryId::generate(); + assert!(first < second); + } + + #[test] + fn serde_uses_the_plain_string_and_validates_it() { + let id = CrewId::generate(); + let json = serde_json::to_string(&id).expect("serialize"); + assert_eq!(json, format!("\"{id}\"")); + assert_eq!( + serde_json::from_str::(&json).expect("deserialize"), + id + ); + assert!(serde_json::from_str::("\"bot_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0\"").is_err()); + } +} diff --git a/crates/botloft-core/src/lib.rs b/crates/botloft-core/src/lib.rs index ed64a112..4611e5b7 100644 --- a/crates/botloft-core/src/lib.rs +++ b/crates/botloft-core/src/lib.rs @@ -1,15 +1,18 @@ //! Domain types, IDs, message envelope rendering and protocol types shared by //! the daemon and the app. -/// Version of the app <-> daemon JSON-RPC protocol, negotiated in `session.hello`. -pub const PROTOCOL_VERSION: u32 = 1; +pub mod avatar; +pub mod ids; +pub mod protocol; +pub mod slug; +pub mod validate; -#[cfg(test)] -mod tests { - use super::*; +pub use protocol::PROTOCOL_VERSION; - #[test] - fn protocol_version_starts_at_one() { - assert_eq!(PROTOCOL_VERSION, 1); - } +/// Current time in milliseconds since the Unix epoch. +pub fn now_ms() -> i64 { + let elapsed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX) } diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs new file mode 100644 index 00000000..0f0d76c8 --- /dev/null +++ b/crates/botloft-core/src/protocol/export.rs @@ -0,0 +1,177 @@ +//! Writes `app/src/lib/protocol.gen.ts`. Run it with +//! `cargo test -p botloft-core export_bindings`; CI fails if the committed +//! file differs from what this produces. + +use std::fmt::Write as _; +use std::path::Path; + +use ts_rs::{Config, TS}; + +use super::*; +use crate::ids::{BotId, CrewId, DeliveryId, MessageId, TaskId}; + +const HEADER: &str = "\ +// Generated by `cargo test -p botloft-core export_bindings`. Do not edit by hand. +// Source: crates/botloft-core/src/protocol. +"; + +struct Out { + cfg: Config, + text: String, +} + +impl Out { + fn decl(&mut self) { + if let Some(docs) = T::docs() { + self.text.push_str(&docs); + } + let _ = writeln!(self.text, "export {}\n", T::decl(&self.cfg)); + } + + fn method(&mut self, name: &str, params: &str, result: &str) { + let _ = writeln!( + self.text, + " \"{name}\": {{ params: {params}; result: {result} }};" + ); + } + + fn name(&self) -> String { + T::name(&self.cfg) + } +} + +#[test] +fn export_bindings() { + let mut out = Out { + cfg: Config::new().with_large_int("number"), + text: String::from(HEADER), + }; + let _ = writeln!( + out.text, + "\nexport const PROTOCOL_VERSION = {PROTOCOL_VERSION};\n" + ); + + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + + let crew = out.name::(); + let bot = out.name::(); + let crews = out.name::>(); + let bots = out.name::>(); + + out.text + .push_str("/** Params and result of every request method. */\n"); + out.text.push_str("export interface RpcMethods {\n"); + out.method( + method::SESSION_HELLO, + &out.name::(), + &out.name::(), + ); + out.method( + method::SYSTEM_STATUS, + "undefined", + &out.name::(), + ); + out.method(method::CREWS_LIST, "undefined", &crews); + out.method( + method::CREWS_CREATE, + &out.name::(), + &crew, + ); + out.method( + method::CREWS_RENAME, + &out.name::(), + &crew, + ); + out.method( + method::CREWS_SET_PAUSED, + &out.name::(), + &crew, + ); + out.method(method::CREWS_ARCHIVE, &out.name::(), &crew); + out.method(method::BOTS_LIST, &out.name::(), &bots); + out.method(method::BOTS_CREATE, &out.name::(), &bot); + out.method(method::BOTS_UPDATE, &out.name::(), &bot); + out.method( + method::BOTS_SET_PAUSED, + &out.name::(), + &bot, + ); + out.method(method::BOTS_ARCHIVE, &out.name::(), &bot); + out.method(method::BOTS_RESTART, &out.name::(), &bot); + let attach = out.name::(); + out.method( + method::TERMINAL_ATTACH, + &out.name::(), + &attach, + ); + out.method(method::TERMINAL_DETACH, &out.name::(), "null"); + out.method( + method::TERMINAL_WRITE, + &out.name::(), + "null", + ); + out.method( + method::TERMINAL_RESIZE, + &out.name::(), + "null", + ); + out.text.push_str("}\n\n"); + + out.text + .push_str("/** Params of every server notification. */\n"); + out.text.push_str("export interface RpcNotifications {\n"); + let _ = writeln!(out.text, " \"{}\": {crew};", notification::CREW_CHANGED); + let _ = writeln!(out.text, " \"{}\": {bot};", notification::BOT_CHANGED); + let state = out.name::(); + let data = out.name::(); + let _ = writeln!(out.text, " \"{}\": {state};", notification::BOT_STATE); + let _ = writeln!(out.text, " \"{}\": {data};", notification::TERMINAL_DATA); + out.text.push_str("}\n\n"); + + out.text.push_str("export const RpcErrorCode = {\n"); + for (name, code) in [ + ("parseError", error_code::PARSE_ERROR), + ("invalidRequest", error_code::INVALID_REQUEST), + ("methodNotFound", error_code::METHOD_NOT_FOUND), + ("invalidParams", error_code::INVALID_PARAMS), + ("internalError", error_code::INTERNAL_ERROR), + ("notAuthenticated", error_code::NOT_AUTHENTICATED), + ("notFound", error_code::NOT_FOUND), + ("conflict", error_code::CONFLICT), + ("validation", error_code::VALIDATION), + ("runtimeUnavailable", error_code::RUNTIME_UNAVAILABLE), + ] { + let _ = writeln!(out.text, " {name}: {code},"); + } + out.text.push_str("} as const;\n"); + + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../app/src/lib/protocol.gen.ts"); + std::fs::write(&path, out.text).expect("write protocol.gen.ts"); +} diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs new file mode 100644 index 00000000..b9de91ca --- /dev/null +++ b/crates/botloft-core/src/protocol/methods.rs @@ -0,0 +1,158 @@ +//! Params and results of the request methods (spec 11.2). + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::ids::{BotId, CrewId}; + +/// Identifies the connecting app in `session.hello`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ClientInfo { + pub name: String, + pub version: String, +} + +/// First request of every connection. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct HelloParams { + /// Owner token from `secrets\owner.token`. + pub token: String, + pub client: ClientInfo, + pub protocol: u32, +} + +impl fmt::Debug for HelloParams { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelloParams") + .field("token", &"") + .field("client", &self.client) + .field("protocol", &self.protocol) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct HelloResult { + pub daemon_version: String, + pub protocol: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct SystemStatus { + pub daemon_version: String, + pub protocol: u32, + pub uptime_ms: i64, + /// Version reported by `claude --version`; `null` until the runtime probe runs. + pub claude_version: Option, + /// Why bots cannot start (Claude Code missing or too old); `null` when fine. + pub runtime_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewsCreateParams { + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewsRenameParams { + pub crew_id: CrewId, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewsSetPausedParams { + pub crew_id: CrewId, + pub paused: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewIdParams { + pub crew_id: CrewId, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotsListParams { + /// Only bots of this crew; every active bot when absent. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub crew_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotsCreateParams { + pub crew_id: CrewId, + pub name: String, + pub role: String, + pub instructions: String, + /// Avatar color `#RRGGBB`; the next palette color when absent. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub color: Option, +} + +/// Fields left out stay unchanged. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotsUpdateParams { + pub bot_id: BotId, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub name: Option, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub role: Option, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub instructions: Option, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub color: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotsSetPausedParams { + pub bot_id: BotId, + pub paused: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotIdParams { + pub bot_id: BotId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotsRestartParams { + pub bot_id: BotId, + /// Start a new conversation instead of resuming the last one. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub fresh: Option, +} diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs new file mode 100644 index 00000000..64a83c60 --- /dev/null +++ b/crates/botloft-core/src/protocol/mod.rs @@ -0,0 +1,59 @@ +//! Types of the app <-> daemon JSON-RPC protocol (spec section 11). The app +//! receives them as TypeScript in `app/src/lib/protocol.gen.ts`, generated by +//! `cargo test -p botloft-core export_bindings`. + +#[cfg(test)] +mod export; +mod methods; +mod model; +mod terminal; + +pub use methods::*; +pub use model::*; +pub use terminal::*; + +/// Version negotiated in `session.hello`. +pub const PROTOCOL_VERSION: u32 = 1; + +/// Request method names. +pub mod method { + pub const SESSION_HELLO: &str = "session.hello"; + pub const SYSTEM_STATUS: &str = "system.status"; + pub const CREWS_LIST: &str = "crews.list"; + pub const CREWS_CREATE: &str = "crews.create"; + pub const CREWS_RENAME: &str = "crews.rename"; + pub const CREWS_SET_PAUSED: &str = "crews.setPaused"; + pub const CREWS_ARCHIVE: &str = "crews.archive"; + pub const BOTS_LIST: &str = "bots.list"; + pub const BOTS_CREATE: &str = "bots.create"; + pub const BOTS_UPDATE: &str = "bots.update"; + pub const BOTS_SET_PAUSED: &str = "bots.setPaused"; + pub const BOTS_ARCHIVE: &str = "bots.archive"; + pub const BOTS_RESTART: &str = "bots.restart"; + pub const TERMINAL_ATTACH: &str = "terminal.attach"; + pub const TERMINAL_DETACH: &str = "terminal.detach"; + pub const TERMINAL_WRITE: &str = "terminal.write"; + pub const TERMINAL_RESIZE: &str = "terminal.resize"; +} + +/// Server notification method names. +pub mod notification { + pub const CREW_CHANGED: &str = "crew.changed"; + pub const BOT_CHANGED: &str = "bot.changed"; + pub const BOT_STATE: &str = "bot.state"; + pub const TERMINAL_DATA: &str = "terminal.data"; +} + +/// Error codes: the standard JSON-RPC ones plus the daemon's (spec 11.4). +pub mod error_code { + pub const PARSE_ERROR: i32 = -32700; + pub const INVALID_REQUEST: i32 = -32600; + pub const METHOD_NOT_FOUND: i32 = -32601; + pub const INVALID_PARAMS: i32 = -32602; + pub const INTERNAL_ERROR: i32 = -32603; + pub const NOT_AUTHENTICATED: i32 = -32001; + pub const NOT_FOUND: i32 = -32002; + pub const CONFLICT: i32 = -32003; + pub const VALIDATION: i32 = -32004; + pub const RUNTIME_UNAVAILABLE: i32 = -32005; +} diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs new file mode 100644 index 00000000..cb17d2b0 --- /dev/null +++ b/crates/botloft-core/src/protocol/model.rs @@ -0,0 +1,77 @@ +//! Entities the daemon returns and broadcasts. + +use serde::{Deserialize, Serialize}; + +use crate::ids::{BotId, CrewId}; + +/// A group of bots that can message each other and share a folder. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Crew { + pub id: CrewId, + pub name: String, + /// Folder name under the workspaces root. Set at creation, never changes. + pub slug: String, + pub paused: bool, + /// Unix time in milliseconds. + pub created_at: i64, + /// Unix time in milliseconds; `null` while the crew is active. + pub archived_at: Option, +} + +/// Lifecycle state of a bot (spec 7.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub enum BotState { + Offline, + Launching, + Idle, + Busy, + NeedsApproval, + RateLimited, + AuthError, + Backoff, + Archived, +} + +/// A persistent Claude Code session with a name, a role and instructions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Bot { + pub id: BotId, + pub crew_id: CrewId, + pub name: String, + /// Address other bots use (`send_message(to: "")`). Derived from + /// the name and unique among the crew's active bots. + pub handle: String, + /// Folder name inside the crew folder. Set at creation, never changes. + pub slug: String, + pub role: String, + pub instructions: String, + /// Avatar color, `#RRGGBB`. + pub color: String, + pub paused: bool, + pub state: BotState, + /// Current process generation; `null` if the bot has not started since + /// the daemon did. Changes on every (re)start (spec 8). + pub generation: Option, + /// Absolute path of the bot's workspace folder. + pub workspace: String, + /// Unix time in milliseconds. + pub created_at: i64, + /// Unix time in milliseconds; `null` while the bot is active. + pub archived_at: Option, +} + +/// Params of the `bot.state` notification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotStateChanged { + pub bot_id: BotId, + pub state: BotState, + pub generation: Option, +} diff --git a/crates/botloft-core/src/protocol/terminal.rs b/crates/botloft-core/src/protocol/terminal.rs new file mode 100644 index 00000000..ae2709dc --- /dev/null +++ b/crates/botloft-core/src/protocol/terminal.rs @@ -0,0 +1,61 @@ +//! `terminal.*` methods and the `terminal.data` notification (spec 8). + +use serde::{Deserialize, Serialize}; + +use crate::ids::BotId; + +/// Starts streaming a bot's terminal. With the `generation` and `offset` the +/// client last saw, only the missing bytes are replayed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct TerminalAttachParams { + pub bot_id: BotId, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub generation: Option, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub offset: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct TerminalAttachResult { + pub generation: u64, + /// Offset of the first byte the following `terminal.data` carries. + pub offset: u64, + /// True when the client must clear its screen before writing the replay. + pub reset: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct TerminalWriteParams { + pub bot_id: BotId, + /// Raw bytes, base64. + pub data: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct TerminalResizeParams { + pub bot_id: BotId, + pub cols: u16, + pub rows: u16, +} + +/// Terminal output. `offset` counts bytes since the start of `generation`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct TerminalData { + pub bot_id: BotId, + pub generation: u64, + pub offset: u64, + /// Raw bytes, base64. May end in the middle of a UTF-8 sequence. + pub data: String, +} diff --git a/crates/botloft-core/src/slug.rs b/crates/botloft-core/src/slug.rs new file mode 100644 index 00000000..f1961067 --- /dev/null +++ b/crates/botloft-core/src/slug.rs @@ -0,0 +1,114 @@ +//! Folder-safe slugs and bot handles derived from display names. +//! +//! Slugs name workspace folders and never change after creation (spec 5). +//! Handles address bots inside a crew and follow the bot's current name. + +/// Longest slug or handle, in characters. Short folder names keep workspace +/// paths well under the Windows 260-character limit. +pub const MAX_LEN: usize = 32; + +/// Device names Windows refuses as file or folder names. +const WINDOWS_RESERVED: &[&str] = &[ + "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", + "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", +]; + +/// Turns a display name into lowercase ASCII letters, digits and single +/// hyphens, transliterating accents (`Revisão` -> `revisao`). +/// +/// Returns `fallback` when nothing usable is left and appends it to names +/// Windows reserves for devices (`con` -> `con-bot`). +pub fn slugify(name: &str, fallback: &str) -> String { + let ascii = deunicode::deunicode(name); + let mut out = String::with_capacity(ascii.len().min(MAX_LEN)); + let mut hyphen_pending = false; + for ch in ascii.chars() { + if ch.is_ascii_alphanumeric() { + if hyphen_pending && !out.is_empty() { + out.push('-'); + } + hyphen_pending = false; + out.push(ch.to_ascii_lowercase()); + } else { + hyphen_pending = true; + } + } + let mut out = truncate(&out, MAX_LEN); + if out.is_empty() { + return fallback.to_owned(); + } + if WINDOWS_RESERVED.contains(&out.as_str()) { + out = format!("{out}-{fallback}"); + } + out +} + +/// Returns `base` if `taken` rejects it, otherwise the first free `base-2`, +/// `base-3`, ... shortened so the result still fits in [`MAX_LEN`]. +pub fn unique(base: &str, mut taken: impl FnMut(&str) -> bool) -> String { + if !taken(base) { + return base.to_owned(); + } + let mut n: u32 = 2; + loop { + let suffix = format!("-{n}"); + let candidate = format!("{}{suffix}", truncate(base, MAX_LEN - suffix.len())); + if !taken(&candidate) { + return candidate; + } + n += 1; + } +} + +/// Cuts an ASCII slug to `max` characters without leaving a trailing hyphen. +fn truncate(slug: &str, max: usize) -> String { + let cut = &slug[..slug.len().min(max)]; + cut.trim_end_matches('-').to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transliterates_and_collapses_separators() { + assert_eq!(slugify("Revisão de Código", "bot"), "revisao-de-codigo"); + assert_eq!(slugify(" Site -- Loja!! ", "crew"), "site-loja"); + assert_eq!(slugify("QA_Bot 2", "bot"), "qa-bot-2"); + } + + #[test] + fn falls_back_when_nothing_is_left() { + assert_eq!(slugify("!!!", "bot"), "bot"); + assert_eq!(slugify("", "crew"), "crew"); + } + + #[test] + fn avoids_windows_device_names() { + assert_eq!(slugify("CON", "bot"), "con-bot"); + assert_eq!(slugify("lpt1", "crew"), "lpt1-crew"); + assert_eq!(slugify("console", "bot"), "console"); + } + + #[test] + fn limits_length_without_trailing_hyphen() { + let slug = slugify("a very long display name for a bot that keeps going", "bot"); + assert!(slug.len() <= MAX_LEN, "{slug}"); + assert!(!slug.ends_with('-')); + } + + #[test] + fn unique_appends_the_first_free_counter() { + let taken = ["docs", "docs-2"]; + assert_eq!(unique("docs", |s| taken.contains(&s)), "docs-3"); + assert_eq!(unique("free", |s| taken.contains(&s)), "free"); + } + + #[test] + fn unique_keeps_suffixed_slugs_within_the_limit() { + let base = "x".repeat(MAX_LEN); + let slug = unique(&base, |s| s == base); + assert_eq!(slug.len(), MAX_LEN); + assert!(slug.ends_with("-2")); + } +} diff --git a/crates/botloft-core/src/validate.rs b/crates/botloft-core/src/validate.rs new file mode 100644 index 00000000..6a53b2f6 --- /dev/null +++ b/crates/botloft-core/src/validate.rs @@ -0,0 +1,78 @@ +//! Validation of owner-provided fields. Error messages are shown to the owner +//! as they are, so they name the field and the rule. + +/// A field value the daemon refuses. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{0}")] +pub struct ValidationError(pub String); + +pub const NAME_MAX_CHARS: usize = 64; +pub const ROLE_MAX_CHARS: usize = 200; +pub const INSTRUCTIONS_MAX_CHARS: usize = 32_000; + +/// Trims a crew or bot name and checks it is a non-empty single line. +pub fn name(field: &str, value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(ValidationError(format!("{field} must not be empty"))); + } + single_line(field, value, NAME_MAX_CHARS) +} + +/// Trims a bot role, a single line that may be empty. +pub fn role(value: &str) -> Result { + single_line("role", value.trim(), ROLE_MAX_CHARS) +} + +/// Checks bot instructions, free multi-line text kept as written. +pub fn instructions(value: &str) -> Result { + if value.chars().count() > INSTRUCTIONS_MAX_CHARS { + return Err(ValidationError(format!( + "instructions must be at most {INSTRUCTIONS_MAX_CHARS} characters" + ))); + } + Ok(value.trim_end().to_owned()) +} + +fn single_line(field: &str, value: &str, max: usize) -> Result { + if value.chars().count() > max { + return Err(ValidationError(format!( + "{field} must be at most {max} characters" + ))); + } + if value.chars().any(char::is_control) { + return Err(ValidationError(format!( + "{field} must be a single line of text" + ))); + } + Ok(value.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_are_trimmed_and_required() { + assert_eq!(name("name", " Revisor "), Ok("Revisor".to_owned())); + assert!(name("name", " ").is_err()); + assert!(name("name", "two\nlines").is_err()); + assert!(name("name", &"x".repeat(NAME_MAX_CHARS + 1)).is_err()); + assert!(name("name", &"é".repeat(NAME_MAX_CHARS)).is_ok()); + } + + #[test] + fn role_may_be_empty_but_single_line() { + assert_eq!(role(""), Ok(String::new())); + assert!(role("reviews\tcode").is_err()); + } + + #[test] + fn instructions_keep_newlines_and_have_a_limit() { + assert_eq!( + instructions("line 1\nline 2\n\n"), + Ok("line 1\nline 2".to_owned()) + ); + assert!(instructions(&"x".repeat(INSTRUCTIONS_MAX_CHARS + 1)).is_err()); + } +} diff --git a/crates/botloft-store/Cargo.toml b/crates/botloft-store/Cargo.toml index 7d84df02..fa94ff98 100644 --- a/crates/botloft-store/Cargo.toml +++ b/crates/botloft-store/Cargo.toml @@ -8,6 +8,12 @@ repository.workspace = true authors.workspace = true [dependencies] +botloft-core.workspace = true +rusqlite = { version = "0.40.2", features = ["bundled"] } +thiserror = "2.0.21" [lints] workspace = true + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/botloft-store/migrations/.gitkeep b/crates/botloft-store/migrations/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/crates/botloft-store/migrations/0001_init.sql b/crates/botloft-store/migrations/0001_init.sql new file mode 100644 index 00000000..37207ee5 --- /dev/null +++ b/crates/botloft-store/migrations/0001_init.sql @@ -0,0 +1,38 @@ +-- Crews, bots and daemon settings (spec 12). Times are Unix milliseconds. +-- Archiving is logical: archived_at is set and rows are kept. + +CREATE TABLE crews ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + paused INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + archived_at INTEGER +); + +CREATE TABLE bots ( + id TEXT PRIMARY KEY, + crew_id TEXT NOT NULL REFERENCES crews (id), + name TEXT NOT NULL, + handle TEXT NOT NULL, + slug TEXT NOT NULL, + role TEXT NOT NULL, + instructions TEXT NOT NULL, + color TEXT NOT NULL, + paused INTEGER NOT NULL DEFAULT 0, + -- SHA-256 of the current generation's bot token; set by the runtime (M2). + token_hash TEXT, + created_at INTEGER NOT NULL, + archived_at INTEGER, + UNIQUE (crew_id, slug) +); + +CREATE INDEX bots_crew ON bots (crew_id); + +-- Handles address bots inside a crew, so they are unique among active bots. +CREATE UNIQUE INDEX bots_active_handle ON bots (crew_id, handle) WHERE archived_at IS NULL; + +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); diff --git a/crates/botloft-store/src/bots.rs b/crates/botloft-store/src/bots.rs new file mode 100644 index 00000000..b1f77099 --- /dev/null +++ b/crates/botloft-store/src/bots.rs @@ -0,0 +1,262 @@ +//! `bots` table. + +use botloft_core::ids::{BotId, CrewId}; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::{Result, Store, parse_column, unique_as_duplicate}; + +/// A bot as stored. The protocol `Bot` adds runtime state and the workspace +/// path, which the daemon derives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BotRecord { + pub id: BotId, + pub crew_id: CrewId, + pub name: String, + pub handle: String, + pub slug: String, + pub role: String, + pub instructions: String, + pub color: String, + pub paused: bool, + pub created_at: i64, + pub archived_at: Option, +} + +const COLUMNS: &str = + "id, crew_id, name, handle, slug, role, instructions, color, paused, created_at, archived_at"; + +fn from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(BotRecord { + id: parse_column(row, 0)?, + crew_id: parse_column(row, 1)?, + name: row.get(2)?, + handle: row.get(3)?, + slug: row.get(4)?, + role: row.get(5)?, + instructions: row.get(6)?, + color: row.get(7)?, + paused: row.get(8)?, + created_at: row.get(9)?, + archived_at: row.get(10)?, + }) +} + +impl Store { + pub fn insert_bot(&self, bot: &BotRecord) -> Result<()> { + self.conn + .execute( + &format!( + "INSERT INTO bots ({COLUMNS}) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)" + ), + params![ + bot.id.as_str(), + bot.crew_id.as_str(), + bot.name, + bot.handle, + bot.slug, + bot.role, + bot.instructions, + bot.color, + bot.paused, + bot.created_at, + bot.archived_at + ], + ) + .map_err(|err| unique_as_duplicate(err, "bot handle or slug"))?; + Ok(()) + } + + pub fn bot(&self, id: &BotId) -> Result> { + Ok(self + .conn + .query_row( + &format!("SELECT {COLUMNS} FROM bots WHERE id = ?1"), + [id.as_str()], + from_row, + ) + .optional()?) + } + + /// Bots in creation order, optionally limited to one crew. + pub fn bots(&self, crew: Option<&CrewId>, include_archived: bool) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "SELECT {COLUMNS} FROM bots \ + WHERE (?1 IS NULL OR crew_id = ?1) AND (?2 OR archived_at IS NULL) \ + ORDER BY created_at, id" + ))?; + let rows = stmt.query_map( + params![crew.map(CrewId::as_str), include_archived], + from_row, + )?; + Ok(rows.collect::>()?) + } + + /// Whether any bot of the crew, archived or not, already uses `slug`. + pub fn bot_slug_exists(&self, crew: &CrewId, slug: &str) -> Result { + Ok(self.conn.query_row( + "SELECT EXISTS (SELECT 1 FROM bots WHERE crew_id = ?1 AND slug = ?2)", + params![crew.as_str(), slug], + |row| row.get(0), + )?) + } + + /// The active bot of the crew that answers to `handle`, if any. + pub fn active_bot_by_handle(&self, crew: &CrewId, handle: &str) -> Result> { + Ok(self + .conn + .query_row( + "SELECT id FROM bots WHERE crew_id = ?1 AND handle = ?2 AND archived_at IS NULL", + params![crew.as_str(), handle], + |row| parse_column(row, 0), + ) + .optional()?) + } + + /// Bots ever created in the crew, archived ones included. + pub fn count_bots(&self, crew: &CrewId) -> Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM bots WHERE crew_id = ?1", + [crew.as_str()], + |row| row.get(0), + )?; + Ok(usize::try_from(count).unwrap_or(0)) + } + + /// Saves every field except the id, crew, slug and creation time. + pub fn update_bot(&self, bot: &BotRecord) -> Result<()> { + self.conn + .execute( + "UPDATE bots SET name = ?2, handle = ?3, role = ?4, instructions = ?5, \ + color = ?6, paused = ?7, archived_at = ?8 WHERE id = ?1", + params![ + bot.id.as_str(), + bot.name, + bot.handle, + bot.role, + bot.instructions, + bot.color, + bot.paused, + bot.archived_at + ], + ) + .map_err(|err| unique_as_duplicate(err, "bot handle"))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use botloft_core::protocol::Crew; + + use super::*; + use crate::StoreError; + + fn setup() -> (Store, CrewId) { + let store = Store::open_in_memory().expect("store"); + let crew = Crew { + id: CrewId::generate(), + name: "Docs".to_owned(), + slug: "docs".to_owned(), + paused: false, + created_at: 1, + archived_at: None, + }; + store.insert_crew(&crew).expect("crew"); + (store, crew.id) + } + + fn bot(crew: &CrewId, handle: &str, created_at: i64) -> BotRecord { + BotRecord { + id: BotId::generate(), + crew_id: crew.clone(), + name: handle.to_owned(), + handle: handle.to_owned(), + slug: handle.to_owned(), + role: "writes docs".to_owned(), + instructions: "Be brief.\nCite sources.".to_owned(), + color: "#FF7A59".to_owned(), + paused: false, + created_at, + archived_at: None, + } + } + + #[test] + fn insert_get_and_list_by_crew() { + let (store, crew) = setup(); + let writer = bot(&crew, "writer", 10); + let editor = bot(&crew, "editor", 20); + store.insert_bot(&writer).expect("insert"); + store.insert_bot(&editor).expect("insert"); + + assert_eq!(store.bot(&writer.id).expect("get"), Some(writer.clone())); + let expected = vec![writer, editor]; + assert_eq!(store.bots(Some(&crew), false).expect("list"), expected); + assert_eq!(store.bots(None, false).expect("list"), expected); + assert!( + store + .bots(Some(&CrewId::generate()), false) + .expect("list") + .is_empty() + ); + } + + #[test] + fn bot_needs_an_existing_crew() { + let (store, _) = setup(); + let orphan = store.insert_bot(&bot(&CrewId::generate(), "orphan", 1)); + assert!(matches!(orphan, Err(StoreError::Sqlite(_))), "{orphan:?}"); + } + + #[test] + fn handles_are_unique_among_active_bots_only() { + let (store, crew) = setup(); + let mut first = bot(&crew, "writer", 1); + store.insert_bot(&first).expect("insert"); + assert_eq!( + store.active_bot_by_handle(&crew, "writer").expect("lookup"), + Some(first.id.clone()) + ); + + let mut clash = bot(&crew, "writer", 2); + clash.slug = "writer-2".to_owned(); + assert!(matches!( + store.insert_bot(&clash), + Err(StoreError::Duplicate(_)) + )); + + first.archived_at = Some(3); + store.update_bot(&first).expect("archive"); + assert_eq!( + store.active_bot_by_handle(&crew, "writer").expect("lookup"), + None + ); + store.insert_bot(&clash).expect("handle is free again"); + assert!(store.bot_slug_exists(&crew, "writer").expect("slug")); + assert_eq!(store.count_bots(&crew).expect("count"), 2); + } + + #[test] + fn update_saves_mutable_fields() { + let (store, crew) = setup(); + let mut writer = bot(&crew, "writer", 1); + store.insert_bot(&writer).expect("insert"); + writer.name = "Lead Writer".to_owned(); + writer.handle = "lead-writer".to_owned(); + writer.color = "#5EC8FF".to_owned(); + writer.paused = true; + store.update_bot(&writer).expect("update"); + assert_eq!(store.bot(&writer.id).expect("get"), Some(writer)); + } + + #[test] + fn archiving_the_crew_archives_its_bots() { + let (store, crew) = setup(); + store.insert_bot(&bot(&crew, "writer", 1)).expect("insert"); + store.archive_crew(&crew, 9).expect("archive"); + assert!(store.bots(Some(&crew), false).expect("list").is_empty()); + let all = store.bots(Some(&crew), true).expect("list all"); + assert_eq!(all[0].archived_at, Some(9)); + } +} diff --git a/crates/botloft-store/src/crews.rs b/crates/botloft-store/src/crews.rs new file mode 100644 index 00000000..e56c8061 --- /dev/null +++ b/crates/botloft-store/src/crews.rs @@ -0,0 +1,152 @@ +//! `crews` table. + +use botloft_core::ids::CrewId; +use botloft_core::protocol::Crew; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::{Result, Store, parse_column, unique_as_duplicate}; + +const COLUMNS: &str = "id, name, slug, paused, created_at, archived_at"; + +fn from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(Crew { + id: parse_column(row, 0)?, + name: row.get(1)?, + slug: row.get(2)?, + paused: row.get(3)?, + created_at: row.get(4)?, + archived_at: row.get(5)?, + }) +} + +impl Store { + pub fn insert_crew(&self, crew: &Crew) -> Result<()> { + self.conn + .execute( + &format!("INSERT INTO crews ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"), + params![ + crew.id.as_str(), + crew.name, + crew.slug, + crew.paused, + crew.created_at, + crew.archived_at + ], + ) + .map_err(|err| unique_as_duplicate(err, "crew slug"))?; + Ok(()) + } + + pub fn crew(&self, id: &CrewId) -> Result> { + Ok(self + .conn + .query_row( + &format!("SELECT {COLUMNS} FROM crews WHERE id = ?1"), + [id.as_str()], + from_row, + ) + .optional()?) + } + + /// Crews in creation order. + pub fn crews(&self, include_archived: bool) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "SELECT {COLUMNS} FROM crews WHERE ?1 OR archived_at IS NULL ORDER BY created_at, id" + ))?; + let rows = stmt.query_map([include_archived], from_row)?; + Ok(rows.collect::>()?) + } + + /// Whether any crew, archived or not, already uses `slug`. + pub fn crew_slug_exists(&self, slug: &str) -> Result { + Ok(self.conn.query_row( + "SELECT EXISTS (SELECT 1 FROM crews WHERE slug = ?1)", + [slug], + |row| row.get(0), + )?) + } + + /// Saves the mutable fields: name, paused and archived_at. + pub fn update_crew(&self, crew: &Crew) -> Result<()> { + self.conn.execute( + "UPDATE crews SET name = ?2, paused = ?3, archived_at = ?4 WHERE id = ?1", + params![crew.id.as_str(), crew.name, crew.paused, crew.archived_at], + )?; + Ok(()) + } + + /// Archives the crew and every active bot in it, atomically. + pub fn archive_crew(&self, id: &CrewId, at: i64) -> Result<()> { + let tx = self.conn.unchecked_transaction()?; + tx.execute( + "UPDATE crews SET archived_at = ?2 WHERE id = ?1 AND archived_at IS NULL", + params![id.as_str(), at], + )?; + tx.execute( + "UPDATE bots SET archived_at = ?2 WHERE crew_id = ?1 AND archived_at IS NULL", + params![id.as_str(), at], + )?; + tx.commit()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::StoreError; + + pub(crate) fn crew(slug: &str, created_at: i64) -> Crew { + Crew { + id: CrewId::generate(), + name: slug.to_uppercase(), + slug: slug.to_owned(), + paused: false, + created_at, + archived_at: None, + } + } + + #[test] + fn insert_get_and_list_in_creation_order() { + let store = Store::open_in_memory().expect("store"); + let second = crew("second", 20); + let first = crew("first", 10); + store.insert_crew(&second).expect("insert"); + store.insert_crew(&first).expect("insert"); + + assert_eq!(store.crew(&first.id).expect("get"), Some(first.clone())); + assert_eq!(store.crew(&CrewId::generate()).expect("get"), None); + assert_eq!(store.crews(false).expect("list"), vec![first, second]); + } + + #[test] + fn slugs_are_unique_even_after_archiving() { + let store = Store::open_in_memory().expect("store"); + let docs = crew("docs", 1); + store.insert_crew(&docs).expect("insert"); + store.archive_crew(&docs.id, 2).expect("archive"); + + assert!(store.crew_slug_exists("docs").expect("exists")); + assert!(!store.crew_slug_exists("other").expect("exists")); + let clash = store.insert_crew(&crew("docs", 3)); + assert!(matches!(clash, Err(StoreError::Duplicate(_)))); + } + + #[test] + fn update_and_archive_filter_the_listing() { + let store = Store::open_in_memory().expect("store"); + let mut docs = crew("docs", 1); + store.insert_crew(&docs).expect("insert"); + docs.name = "Docs team".to_owned(); + docs.paused = true; + store.update_crew(&docs).expect("update"); + assert_eq!(store.crew(&docs.id).expect("get"), Some(docs.clone())); + + store.archive_crew(&docs.id, 5).expect("archive"); + assert!(store.crews(false).expect("list").is_empty()); + let all = store.crews(true).expect("list all"); + assert_eq!(all.len(), 1); + assert_eq!(all[0].archived_at, Some(5)); + } +} diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index 416349b0..bf068417 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -1,2 +1,130 @@ //! SQLite storage for the Botloft daemon: connection setup, numbered //! migrations (`migrations/NNNN_name.sql`) and repositories. +//! +//! One [`Store`] wraps one connection. The daemon keeps a single store behind +//! a mutex; queries are short, so there is no connection pool. + +mod bots; +mod crews; +mod migrate; + +use std::path::Path; +use std::str::FromStr; +use std::time::Duration; + +use rusqlite::types::Type; +use rusqlite::{Connection, Row, ffi}; + +pub use bots::BotRecord; +pub use migrate::LATEST_VERSION; + +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("database error: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("a row with the same {0} already exists")] + Duplicate(&'static str), + #[error("database schema version {found} is newer than this build supports ({supported})")] + TooNew { found: u32, supported: u32 }, +} + +pub type Result = std::result::Result; + +pub struct Store { + conn: Connection, +} + +impl Store { + /// Opens (or creates) the database file and brings its schema up to date. + pub fn open(path: &Path) -> Result { + Self::init(Connection::open(path)?) + } + + /// A private in-memory database, for tests. + pub fn open_in_memory() -> Result { + Self::init(Connection::open_in_memory()?) + } + + fn init(mut conn: Connection) -> Result { + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + conn.busy_timeout(Duration::from_millis(5000))?; + migrate::run(&mut conn)?; + Ok(Self { conn }) + } + + /// Schema version recorded in `PRAGMA user_version`. + pub fn schema_version(&self) -> Result { + Ok(self + .conn + .pragma_query_value(None, "user_version", |row| row.get(0))?) + } +} + +/// Reads a text column into a parsed type such as an ID. +fn parse_column(row: &Row<'_>, idx: usize) -> rusqlite::Result +where + T: FromStr, + T::Err: std::error::Error + Send + Sync + 'static, +{ + let raw: String = row.get(idx)?; + raw.parse() + .map_err(|err| rusqlite::Error::FromSqlConversionFailure(idx, Type::Text, Box::new(err))) +} + +/// Turns a UNIQUE or PRIMARY KEY failure into [`StoreError::Duplicate`]; +/// other constraint failures (a missing foreign key, say) stay as they are. +fn unique_as_duplicate(err: rusqlite::Error, what: &'static str) -> StoreError { + match &err { + rusqlite::Error::SqliteFailure(e, _) + if e.extended_code == ffi::SQLITE_CONSTRAINT_UNIQUE + || e.extended_code == ffi::SQLITE_CONSTRAINT_PRIMARYKEY => + { + StoreError::Duplicate(what) + } + _ => StoreError::Sqlite(err), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_database_uses_wal_and_foreign_keys() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = Store::open(&dir.path().join("botloft.db")).expect("open"); + let mode: String = store + .conn + .pragma_query_value(None, "journal_mode", |row| row.get(0)) + .expect("journal_mode"); + let fk: i64 = store + .conn + .pragma_query_value(None, "foreign_keys", |row| row.get(0)) + .expect("foreign_keys"); + assert_eq!(mode, "wal"); + assert_eq!(fk, 1); + assert_eq!(store.schema_version().expect("version"), LATEST_VERSION); + } + + #[test] + fn reopening_keeps_data_and_does_not_rerun_migrations() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("botloft.db"); + { + let store = Store::open(&path).expect("open"); + store + .conn + .execute("INSERT INTO settings (key, value) VALUES ('k', 'v')", []) + .expect("insert"); + } + let store = Store::open(&path).expect("reopen"); + let value: String = store + .conn + .query_row("SELECT value FROM settings WHERE key = 'k'", [], |r| { + r.get(0) + }) + .expect("select"); + assert_eq!(value, "v"); + } +} diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs new file mode 100644 index 00000000..66151192 --- /dev/null +++ b/crates/botloft-store/src/migrate.rs @@ -0,0 +1,60 @@ +//! Schema migrations. Each file in `migrations/` runs once, in order, inside +//! a transaction that also bumps `PRAGMA user_version`. + +use rusqlite::Connection; + +use crate::{Result, StoreError}; + +/// `(version, sql)`; versions are consecutive and match the file prefix. +const MIGRATIONS: &[(u32, &str)] = &[(1, include_str!("../migrations/0001_init.sql"))]; + +/// Schema version after every migration has run. +pub const LATEST_VERSION: u32 = MIGRATIONS.len() as u32; + +pub(crate) fn run(conn: &mut Connection) -> Result<()> { + let current: u32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + if current > LATEST_VERSION { + return Err(StoreError::TooNew { + found: current, + supported: LATEST_VERSION, + }); + } + for &(version, sql) in MIGRATIONS.iter().filter(|(v, _)| *v > current) { + let tx = conn.transaction()?; + tx.execute_batch(sql)?; + tx.pragma_update(None, "user_version", version)?; + tx.commit()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_are_consecutive_from_one() { + for (i, (version, _)) in MIGRATIONS.iter().enumerate() { + assert_eq!(*version as usize, i + 1); + } + } + + #[test] + fn migrating_twice_is_a_no_op() { + let mut conn = Connection::open_in_memory().expect("open"); + run(&mut conn).expect("first run"); + run(&mut conn).expect("second run"); + let version: u32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .expect("version"); + assert_eq!(version, LATEST_VERSION); + } + + #[test] + fn refuses_a_database_from_a_newer_build() { + let mut conn = Connection::open_in_memory().expect("open"); + conn.pragma_update(None, "user_version", LATEST_VERSION + 1) + .expect("set version"); + assert!(matches!(run(&mut conn), Err(StoreError::TooNew { .. }))); + } +} diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index a83867cd..b9ab4caf 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -8,8 +8,36 @@ repository.workspace = true authors.workspace = true [dependencies] +anyhow = "1.0.104" +axum = { version = "0.8.9", features = ["ws"] } +base64 = "0.23.1" botloft-core.workspace = true botloft-store.workspace = true +bytes = "1.12.1" +clap = { version = "4.6.7", features = ["derive"] } +dirs = "7.0.0" +futures-util = "0.3.34" +getrandom = "0.4.3" +hex = "0.4.3" +portable-pty = "0.9.0" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sha2 = "0.11.0" +thiserror = "2.0.21" +tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "net", "sync", "signal", "time"] } +toml = "1.1.6" +tracing = "0.1.44" +tracing-appender = "0.2.5" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } [lints] workspace = true + +[target."cfg(windows)".dependencies] +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment"] } + +[dev-dependencies] +base64 = "0.23.1" +tempfile = "3.27.0" +tokio = { version = "1.53.1", features = ["test-util", "macros", "rt-multi-thread"] } +tokio-tungstenite = "0.30.0" diff --git a/crates/botloftd/src/config.rs b/crates/botloftd/src/config.rs new file mode 100644 index 00000000..fbd1c5c4 --- /dev/null +++ b/crates/botloftd/src/config.rs @@ -0,0 +1,213 @@ +//! `config.toml` (spec 6). Every key is optional; missing keys take the +//! defaults below. Unknown keys are rejected so typos do not go unnoticed. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + /// TCP port on 127.0.0.1. + pub port: u16, + /// Empty means `%USERPROFILE%\Botloft`. + pub workspaces_root: String, + /// Empty means resolve `claude` from PATH. + pub claude_path: String, + pub keep_awake: bool, + pub log_level: String, + pub supervisor: SupervisorConfig, + pub courier: CourierConfig, + pub terminal: TerminalConfig, + pub tasks: TasksConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + port: 45710, + workspaces_root: String::new(), + claude_path: String::new(), + keep_awake: true, + log_level: "info".to_owned(), + supervisor: SupervisorConfig::default(), + courier: CourierConfig::default(), + terminal: TerminalConfig::default(), + tasks: TasksConfig::default(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SupervisorConfig { + pub restart_backoff_initial_ms: u64, + pub restart_backoff_max_ms: u64, + pub fresh_start_if_dies_within_s: u64, +} + +impl Default for SupervisorConfig { + fn default() -> Self { + Self { + restart_backoff_initial_ms: 1_000, + restart_backoff_max_ms: 300_000, + fresh_start_if_dies_within_s: 15, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct CourierConfig { + pub poll_interval_ms: u64, + pub lease_ms: u64, + pub max_attempts: u32, + pub retry_backoff_initial_ms: u64, + pub retry_backoff_max_ms: u64, +} + +impl Default for CourierConfig { + fn default() -> Self { + Self { + poll_interval_ms: 500, + lease_ms: 15_000, + max_attempts: 8, + retry_backoff_initial_ms: 2_000, + retry_backoff_max_ms: 120_000, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct TerminalConfig { + pub ring_buffer_bytes: usize, +} + +impl Default for TerminalConfig { + fn default() -> Self { + Self { + ring_buffer_bytes: 1 << 20, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct TasksConfig { + pub max_hops: u32, + pub default_deadline_minutes: u32, +} + +impl Default for TasksConfig { + fn default() -> Self { + Self { + max_hops: 4, + default_deadline_minutes: 120, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("cannot read {path}: {source}")] + Read { + path: PathBuf, + source: std::io::Error, + }, + #[error("invalid config {path}: {message}")] + Invalid { path: PathBuf, message: String }, +} + +const LOG_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"]; + +impl Config { + /// Reads the file at `path`. A missing file yields the defaults unless + /// the path was given explicitly (`required`). + pub fn load(path: &Path, required: bool) -> Result { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == std::io::ErrorKind::NotFound && !required => { + return Ok(Self::default()); + } + Err(source) => { + return Err(ConfigError::Read { + path: path.to_owned(), + source, + }); + } + }; + Self::parse(&text).map_err(|message| ConfigError::Invalid { + path: path.to_owned(), + message, + }) + } + + pub fn parse(text: &str) -> Result { + let config: Self = toml::from_str(text).map_err(|err| err.message().to_owned())?; + if config.port == 0 { + return Err("port must be between 1 and 65535".to_owned()); + } + if !LOG_LEVELS.contains(&config.log_level.as_str()) { + return Err(format!( + "log_level must be one of {}", + LOG_LEVELS.join(", ") + )); + } + Ok(config) + } + + /// The configured workspaces root, if one was set. + pub fn workspaces_root(&self) -> Option { + let root = self.workspaces_root.trim(); + (!root.is_empty()).then(|| PathBuf::from(root)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_file_gives_spec_defaults() { + let config = Config::parse("").expect("parse"); + assert_eq!(config, Config::default()); + assert_eq!(config.port, 45710); + assert_eq!(config.courier.max_attempts, 8); + assert_eq!(config.workspaces_root(), None); + } + + #[test] + fn keys_override_defaults() { + let config = Config::parse( + "port = 45999\nworkspaces_root = 'D:\\\\bots'\n[courier]\nmax_attempts = 3\n", + ) + .expect("parse"); + assert_eq!(config.port, 45999); + assert_eq!(config.courier.max_attempts, 3); + assert_eq!(config.courier.lease_ms, 15_000); + assert_eq!(config.workspaces_root(), Some(PathBuf::from("D:\\bots"))); + } + + #[test] + fn rejects_unknown_keys_and_bad_values() { + assert!(Config::parse("prot = 1").is_err()); + assert!(Config::parse("[courier]\nmax_attempt = 1").is_err()); + assert!(Config::parse("port = 0").is_err()); + assert!(Config::parse("log_level = 'loud'").is_err()); + } + + #[test] + fn missing_file_is_fine_unless_required() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("config.toml"); + assert_eq!( + Config::load(&path, false).expect("defaults"), + Config::default() + ); + assert!(matches!( + Config::load(&path, true), + Err(ConfigError::Read { .. }) + )); + } +} diff --git a/crates/botloftd/src/hooks/client.rs b/crates/botloftd/src/hooks/client.rs new file mode 100644 index 00000000..ef98fcb9 --- /dev/null +++ b/crates/botloftd/src/hooks/client.rs @@ -0,0 +1,164 @@ +//! `botloftd hook `, which Claude Code runs for every hook (spec 7.6). +//! +//! It always exits 0 and never writes to stdout: a SessionStart hook's +//! stdout becomes part of Claude's context. Failures go to +//! `\logs\hook.log`, without the token. + +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpStream}; +use std::path::PathBuf; +use std::time::Duration; + +use serde_json::Value; + +use super::{EVENTS, HookRequest}; + +const TIMEOUT: Duration = Duration::from_secs(3); +const MAX_STDIN: u64 = 1 << 20; + +/// Entry point of the subcommand. Never fails. +pub fn run(event: &str) { + if let Err(err) = forward(event, &mut io::stdin().take(MAX_STDIN)) { + log_failure(event, &err); + } +} + +fn forward(event: &str, stdin: &mut impl Read) -> Result<(), String> { + if !EVENTS.iter().any(|(_, arg)| *arg == event) { + return Err(format!("unknown hook event `{event}`")); + } + let token = var("BOTLOFT_BOT_TOKEN")?; + let port: u16 = var("BOTLOFT_PORT")? + .parse() + .map_err(|_| "BOTLOFT_PORT is not a port number".to_owned())?; + + let mut input = Vec::new(); + stdin + .read_to_end(&mut input) + .map_err(|err| format!("cannot read stdin: {err}"))?; + let mut request = HookRequest { + payload: serde_json::from_slice(&input).unwrap_or(Value::Null), + ..HookRequest::default() + }; + if event == "session-start" { + request.messaging_socket = std::env::var("CLAUDE_CODE_MESSAGING_SOCKET").ok(); + request.messaging_token = std::env::var("CLAUDE_CODE_MESSAGING_TOKEN").ok(); + } + let body = serde_json::to_vec(&request).map_err(|err| err.to_string())?; + post(port, &format!("/hooks/{event}"), &token, &body) +} + +fn var(name: &str) -> Result { + std::env::var(name) + .map_err(|_| format!("{name} is not set; is this running inside a Botloft bot?")) +} + +/// A minimal HTTP/1.1 POST to the daemon on 127.0.0.1. +pub fn post(port: u16, path: &str, token: &str, body: &[u8]) -> Result<(), String> { + let fail = |step: &str, err: io::Error| format!("{step} 127.0.0.1:{port} failed: {err}"); + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let mut stream = + TcpStream::connect_timeout(&addr, TIMEOUT).map_err(|e| fail("connecting to", e))?; + stream + .set_read_timeout(Some(TIMEOUT)) + .map_err(|e| fail("configuring", e))?; + stream + .set_write_timeout(Some(TIMEOUT)) + .map_err(|e| fail("configuring", e))?; + let head = format!( + "POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {token}\r\n\ + Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream + .write_all(head.as_bytes()) + .and_then(|()| stream.write_all(body)) + .map_err(|e| fail("writing to", e))?; + let mut status = String::new(); + BufReader::new(stream) + .read_line(&mut status) + .map_err(|e| fail("reading from", e))?; + match status.split_whitespace().nth(1) { + Some(code) if code.starts_with('2') => Ok(()), + Some(code) => Err(format!("the daemon answered {code} to {path}")), + None => Err(format!("the daemon closed the connection on {path}")), + } +} + +fn log_failure(event: &str, err: &str) { + let Some(home) = std::env::var_os("BOTLOFT_HOME") else { + return; + }; + let path = PathBuf::from(home).join("logs").join("hook.log"); + let line = format!("{} hook {event}: {err}\n", botloft_core::now_ms()); + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|mut file| file.write_all(line.as_bytes())); +} + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + + use super::*; + + /// Serves one request, answers `status` and returns what it received. + fn one_request(status: &'static str) -> (u16, std::thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .expect("timeout"); + let mut received = Vec::new(); + let mut buf = [0u8; 4096]; + while let Ok(n) = stream.read(&mut buf) { + if n == 0 { + break; + } + received.extend_from_slice(&buf[..n]); + if received.windows(4).any(|w| w == b"\r\n\r\n") && received.ends_with(b"}") { + break; + } + } + stream + .write_all(format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\n\r\n").as_bytes()) + .expect("answer"); + String::from_utf8_lossy(&received).into_owned() + }); + (port, server) + } + + #[test] + fn posts_the_payload_with_the_bot_token() { + let (port, server) = one_request("204 No Content"); + post(port, "/hooks/stop", "tok", br#"{"payload":{}}"#).expect("post"); + let request = server.join().expect("server"); + assert!( + request.starts_with("POST /hooks/stop HTTP/1.1\r\n"), + "{request}" + ); + assert!(request.contains("Authorization: Bearer tok\r\n")); + assert!(request.ends_with(r#"{"payload":{}}"#)); + } + + #[test] + fn a_refusal_is_an_error_without_the_token() { + let (port, server) = one_request("401 Unauthorized"); + let err = post(port, "/hooks/stop", "secret-token", b"{}").expect_err("refused"); + server.join().expect("server"); + assert!( + err.contains("401") && !err.contains("secret-token"), + "{err}" + ); + } + + #[test] + fn unknown_events_are_rejected_before_any_network_call() { + let err = forward("explode", &mut io::empty()).expect_err("unknown"); + assert!(err.contains("explode")); + } +} diff --git a/crates/botloftd/src/hooks/mod.rs b/crates/botloftd/src/hooks/mod.rs new file mode 100644 index 00000000..11838f13 --- /dev/null +++ b/crates/botloftd/src/hooks/mod.rs @@ -0,0 +1,169 @@ +//! Claude Code hooks (spec 7.5 and 7.6). Each bot runs `botloftd hook +//! ` (see [`client`]), which forwards the hook to `POST /hooks/` +//! with the bot's token; [`handle`] turns it into a supervisor [`Hook`]. + +pub mod client; + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode, header}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::state::Daemon; +use crate::supervisor::{Hook, Inbox}; + +/// Hook events registered in each bot's settings, with the argument +/// `botloftd hook` receives for each. +pub const EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("Stop", "stop"), + ("StopFailure", "stop-failure"), + ("Notification", "notification"), + ("SessionEnd", "session-end"), +]; + +/// Body of `POST /hooks/`. +#[derive(Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HookRequest { + /// The JSON Claude Code wrote to the hook's stdin. + #[serde(default)] + pub payload: Value, + /// `CLAUDE_CODE_MESSAGING_SOCKET`, sent by `session-start` only. + #[serde(default)] + pub messaging_socket: Option, + /// `CLAUDE_CODE_MESSAGING_TOKEN`, sent by `session-start` only. + #[serde(default)] + pub messaging_token: Option, +} + +/// Reads the payload fields Claude Code documents for each event. +pub fn parse(event: &str, request: HookRequest) -> Option { + let field = |name: &str| { + request + .payload + .get(name) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned() + }; + Some(match event { + "session-start" => Hook::SessionStart { + inbox: match (request.messaging_socket, request.messaging_token) { + (Some(socket), Some(token)) if !socket.is_empty() && !token.is_empty() => { + Some(Inbox { socket, token }) + } + _ => None, + }, + }, + "prompt-submit" => Hook::PromptSubmit, + "stop" => Hook::Stop, + "stop-failure" => Hook::StopFailure { + error: field("error"), + }, + "notification" => Hook::Notification { + kind: field("notification_type"), + }, + "session-end" => Hook::SessionEnd, + _ => return None, + }) +} + +/// `POST /hooks/{event}`. Only the token of a running generation is valid. +pub async fn handle( + State(daemon): State>, + Path(event): Path, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + let token = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")); + let Some((bot, generation)) = token.and_then(|token| daemon.supervisor.hook_owner(token)) + else { + return StatusCode::UNAUTHORIZED; + }; + let request = if body.is_empty() { + HookRequest::default() + } else { + match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_) => return StatusCode::BAD_REQUEST, + } + }; + let Some(hook) = parse(&event, request) else { + return StatusCode::NOT_FOUND; + }; + daemon.supervisor.on_hook(&bot, generation, hook); + StatusCode::NO_CONTENT +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn request(payload: Value) -> HookRequest { + HookRequest { + payload, + ..HookRequest::default() + } + } + + #[test] + fn reads_the_documented_payload_fields() { + let failure = request(json!({ "hook_event_name": "StopFailure", "error": "rate_limit" })); + assert_eq!( + parse("stop-failure", failure), + Some(Hook::StopFailure { + error: "rate_limit".into() + }) + ); + let note = request(json!({ "notification_type": "permission_prompt", "message": "x" })); + assert_eq!( + parse("notification", note), + Some(Hook::Notification { + kind: "permission_prompt".into() + }) + ); + assert_eq!( + parse("prompt-submit", request(Value::Null)), + Some(Hook::PromptSubmit) + ); + assert_eq!(parse("bogus", request(Value::Null)), None); + } + + #[test] + fn session_start_carries_the_inbox_when_both_parts_are_there() { + let full = HookRequest { + payload: json!({ "source": "startup" }), + messaging_socket: Some(r"\\.\pipe\claude-1".into()), + messaging_token: Some("t".into()), + }; + let Some(Hook::SessionStart { inbox: Some(inbox) }) = parse("session-start", full) else { + panic!("expected an inbox"); + }; + assert_eq!(inbox.socket, r"\\.\pipe\claude-1"); + let partial = HookRequest { + messaging_socket: Some("s".into()), + ..HookRequest::default() + }; + assert_eq!( + parse("session-start", partial), + Some(Hook::SessionStart { inbox: None }) + ); + } + + #[test] + fn every_event_has_a_parser() { + for (_, arg) in EVENTS { + assert!(parse(arg, HookRequest::default()).is_some(), "{arg}"); + } + } +} diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs new file mode 100644 index 00000000..7932c06d --- /dev/null +++ b/crates/botloftd/src/lib.rs @@ -0,0 +1,18 @@ +//! `botloftd`: the Botloft daemon. It owns the database, runs the bots and +//! serves the app over JSON-RPC on 127.0.0.1. The binary in `main.rs` only +//! parses the command line and wires these modules together. + +pub mod config; +pub mod hooks; +pub mod logging; +pub mod paths; +pub mod platform; +pub mod rpc; +pub mod runtime; +pub mod secrets; +pub mod server; +pub mod service; +pub mod state; +pub mod supervisor; +pub mod terminal; +pub mod workspace; diff --git a/crates/botloftd/src/logging.rs b/crates/botloftd/src/logging.rs new file mode 100644 index 00000000..2f17ea07 --- /dev/null +++ b/crates/botloftd/src/logging.rs @@ -0,0 +1,39 @@ +//! Logs to `logs\botloftd.YYYY-MM-DD.log` (daily, 14 files kept) and stderr. +//! +//! Never log tokens, message bodies or terminal output at `info` or above: +//! bots may handle personal and health data (spec 13). + +use std::path::Path; + +use tracing_appender::non_blocking::WorkerGuard; +use tracing_appender::rolling::{RollingFileAppender, Rotation}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::fmt; +use tracing_subscriber::prelude::*; + +const KEEP_FILES: usize = 14; + +/// Installs the global subscriber. `level` applies to Botloft's own crates; +/// dependencies stay at `warn` because at debug/trace the WebSocket stack +/// logs frames, which can carry message bodies. `RUST_LOG` overrides both. +/// Keep the returned guard alive: dropping it flushes the file writer. +pub fn init(logs_dir: &Path, level: &str) -> anyhow::Result { + let appender = RollingFileAppender::builder() + .rotation(Rotation::DAILY) + .filename_prefix("botloftd") + .filename_suffix("log") + .max_log_files(KEEP_FILES) + .build(logs_dir)?; + let (file, guard) = tracing_appender::non_blocking(appender); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new(format!( + "warn,botloftd={level},botloft_core={level},botloft_store={level}" + )) + }); + tracing_subscriber::registry() + .with(filter) + .with(fmt::layer().with_ansi(false).with_writer(file)) + .with(fmt::layer().with_writer(std::io::stderr)) + .try_init()?; + Ok(guard) +} diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index ce44c01c..7aedb5ef 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -1,9 +1,104 @@ -//! `botloftd`: the Botloft daemon. - -fn main() { - println!( - "botloftd {} (protocol {})", - env!("CARGO_PKG_VERSION"), - botloft_core::PROTOCOL_VERSION - ); +//! `botloftd` command line. + +use std::net::Ipv4Addr; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Context; +use botloft_store::Store; +use botloftd::config::Config; +use botloftd::paths::{self, Paths}; +use botloftd::platform::{self, InstanceLock}; +use botloftd::runtime::PtyRuntime; +use botloftd::state::{Daemon, DaemonOptions}; +use botloftd::supervisor::{self, SupervisorSettings}; +use botloftd::{hooks, logging, secrets, server}; +use clap::{Parser, Subcommand}; +use tokio::net::TcpListener; +use tracing::info; + +#[derive(Parser)] +#[command(name = "botloftd", version, about = "Botloft daemon")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run the daemon in the foreground. + Serve { + /// Config file. Defaults to config.toml in the data folder. + #[arg(long)] + config: Option, + }, + /// Forward a Claude Code hook to the daemon (run by the bots' settings). + #[command(hide = true)] + Hook { + /// session-start, prompt-submit, stop, stop-failure, notification or session-end. + event: String, + }, +} + +fn main() -> anyhow::Result<()> { + match Cli::parse().command { + Command::Serve { config } => serve(config), + Command::Hook { event } => { + // Always exit 0, even on a panic: a failing hook must never get + // in the way of the bot (spec 7.6). + let _ = std::panic::catch_unwind(|| hooks::client::run(&event)); + std::process::exit(0); + } + } +} + +fn serve(config_path: Option) -> anyhow::Result<()> { + let home = paths::resolve_home().context("cannot resolve the data folder")?; + std::fs::create_dir_all(&home).with_context(|| format!("cannot create {}", home.display()))?; + let config = match &config_path { + Some(path) => Config::load(path, true)?, + None => Config::load(&home.join("config.toml"), false)?, + }; + let paths = Paths::new(home, paths::resolve_workspaces_root(&config)?); + std::fs::create_dir_all(paths.logs())?; + let _log_guard = logging::init(&paths.logs(), &config.log_level)?; + let _lock = InstanceLock::acquire(&paths.lock_file())?; + + let owner_token = secrets::load_or_create_owner_token(&paths.secrets()) + .context("cannot prepare the owner token")?; + let store = Store::open(&paths.db()).context("cannot open the database")?; + std::fs::create_dir_all(&paths.workspaces_root) + .with_context(|| format!("cannot create {}", paths.workspaces_root.display()))?; + let bin = std::env::current_exe().context("cannot find the botloftd executable")?; + + info!(home = %paths.home.display(), workspaces = %paths.workspaces_root.display(), "starting"); + let daemon = Daemon::new(DaemonOptions { + paths, + port: config.port, + bin, + store, + owner_token, + runtime: Arc::new(PtyRuntime), + supervisor: SupervisorSettings::from_config(&config), + }); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(async move { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, config.port)) + .await + .with_context(|| { + format!( + "cannot listen on 127.0.0.1:{}; is another program using the port?", + config.port + ) + })?; + info!(port = config.port, "listening on 127.0.0.1"); + tokio::spawn(supervisor::run(Arc::clone(&daemon))); + server::serve(Arc::clone(&daemon), listener, platform::shutdown_signal()).await?; + daemon.supervisor.shutdown(); + info!("stopped"); + anyhow::Ok(()) + }) } diff --git a/crates/botloftd/src/paths.rs b/crates/botloftd/src/paths.rs new file mode 100644 index 00000000..3c9d2140 --- /dev/null +++ b/crates/botloftd/src/paths.rs @@ -0,0 +1,130 @@ +//! Where the daemon keeps its state and the bots' workspaces (spec 5). + +use std::io; +use std::path::{Path, PathBuf}; + +use crate::config::Config; + +/// Environment variable that moves the daemon's data directory, so a dev +/// daemon never touches a real installation. +pub const HOME_ENV: &str = "BOTLOFT_HOME"; + +/// Name of the folder inside a crew that every bot of the crew shares. +pub const SHARED_DIR: &str = "shared"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Paths { + /// Daemon data: database, secrets, logs, config. Absolute. + pub home: PathBuf, + /// Parent of every crew folder. Absolute. + pub workspaces_root: PathBuf, +} + +impl Paths { + pub fn new(home: PathBuf, workspaces_root: PathBuf) -> Self { + Self { + home, + workspaces_root, + } + } + + pub fn db(&self) -> PathBuf { + self.home.join("botloft.db") + } + + pub fn secrets(&self) -> PathBuf { + self.home.join("secrets") + } + + pub fn logs(&self) -> PathBuf { + self.home.join("logs") + } + + pub fn default_config(&self) -> PathBuf { + self.home.join("config.toml") + } + + /// Held exclusively while a daemon runs for this home (spec 14). + pub fn lock_file(&self) -> PathBuf { + self.home.join("botloftd.lock") + } + + pub fn crew_dir(&self, crew_slug: &str) -> PathBuf { + self.workspaces_root.join(crew_slug) + } + + pub fn shared_dir(&self, crew_slug: &str) -> PathBuf { + self.crew_dir(crew_slug).join(SHARED_DIR) + } + + pub fn bot_workspace(&self, crew_slug: &str, bot_slug: &str) -> PathBuf { + self.crew_dir(crew_slug).join(bot_slug) + } +} + +/// `BOTLOFT_HOME` when set, otherwise `%LOCALAPPDATA%\Botloft` (never the +/// roaming profile). Relative overrides resolve against the current directory. +pub fn resolve_home() -> io::Result { + match std::env::var_os(HOME_ENV).filter(|v| !v.is_empty()) { + Some(home) => std::path::absolute(home), + None => dirs::data_local_dir() + .map(|dir| dir.join("Botloft")) + .ok_or_else(|| io::Error::other("cannot find the local application data folder")), + } +} + +/// `workspaces_root` from the config, otherwise `%USERPROFILE%\Botloft`. +pub fn resolve_workspaces_root(config: &Config) -> io::Result { + match config.workspaces_root() { + Some(root) => std::path::absolute(root), + None => dirs::home_dir() + .map(|dir| dir.join("Botloft")) + .ok_or_else(|| io::Error::other("cannot find the user profile folder")), + } +} + +/// Converts a path to the form Claude Code matches permission rules against: +/// POSIX separators, lowercase drive as the first segment and the `//` prefix +/// that anchors at the filesystem root (`C:\Users\ana` -> `//c/Users/ana`). +pub fn permission_rule_path(path: &Path) -> String { + let text = path.to_string_lossy().replace('\\', "/"); + let text = text.strip_prefix("//?/").unwrap_or(&text); + let bytes = text.as_bytes(); + if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() { + let drive = char::from(bytes[0]).to_ascii_lowercase(); + return format!("//{drive}{}", &text[2..]); + } + format!("//{}", text.trim_start_matches('/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout_follows_the_spec() { + let paths = Paths::new(PathBuf::from("/data"), PathBuf::from("/ws")); + assert_eq!(paths.db(), Path::new("/data/botloft.db")); + assert_eq!(paths.secrets(), Path::new("/data/secrets")); + assert_eq!(paths.shared_dir("docs"), Path::new("/ws/docs/shared")); + assert_eq!( + paths.bot_workspace("docs", "writer"), + Path::new("/ws/docs/writer") + ); + } + + #[test] + fn permission_paths_use_posix_form_with_double_slash() { + let windows = Path::new(r"C:\Users\ana\AppData\Local\Botloft\secrets"); + assert_eq!( + permission_rule_path(windows), + "//c/Users/ana/AppData/Local/Botloft/secrets" + ); + let verbatim = Path::new(r"\\?\D:\data\Botloft"); + assert_eq!(permission_rule_path(verbatim), "//d/data/Botloft"); + assert_eq!( + permission_rule_path(Path::new("/home/ana/.botloft")), + "//home/ana/.botloft" + ); + } +} diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs new file mode 100644 index 00000000..085e696e --- /dev/null +++ b/crates/botloftd/src/platform/mod.rs @@ -0,0 +1,88 @@ +//! Everything that talks to the operating system directly. The rest of the +//! daemon calls these functions and never Win32. + +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; + +use std::fs::{File, OpenOptions, TryLockError}; +use std::io; +use std::path::Path; + +#[cfg(unix)] +pub use unix::{ProcessJob, restrict_to_current_user, user_environment}; +#[cfg(windows)] +pub use windows::{ProcessJob, restrict_to_current_user, user_environment}; + +/// Exclusive lock on `\botloftd.lock`, held for the daemon's lifetime. +/// The OS releases it when the process exits, even after a crash. +#[derive(Debug)] +pub struct InstanceLock { + _file: File, +} + +#[derive(Debug, thiserror::Error)] +pub enum LockError { + #[error("another botloftd is already running with this data folder")] + AlreadyRunning, + #[error("cannot open the lock file: {0}")] + Io(#[from] io::Error), +} + +impl InstanceLock { + pub fn acquire(path: &Path) -> Result { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path)?; + match file.try_lock() { + Ok(()) => Ok(Self { _file: file }), + Err(TryLockError::WouldBlock) => Err(LockError::AlreadyRunning), + Err(TryLockError::Error(err)) => Err(LockError::Io(err)), + } + } +} + +/// Resolves when the daemon should stop: Ctrl+C, and on Windows also the +/// console closing, logoff and shutdown (spec 14). +pub async fn shutdown_signal() { + #[cfg(windows)] + windows::shutdown_signal().await; + #[cfg(unix)] + unix::shutdown_signal().await; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_environment_has_the_basics_and_no_claude_session() { + let vars = user_environment().expect("environment"); + let has = |name: &str| vars.iter().any(|(k, _)| k.eq_ignore_ascii_case(name)); + assert!(has("PATH")); + assert!(!has("CLAUDECODE")); + assert!(!has("CLAUDE_CODE_MESSAGING_SOCKET")); + } + + #[test] + fn a_job_can_be_created_and_terminated_empty() { + let job = ProcessJob::new().expect("job"); + job.terminate().expect("terminate"); + } + + #[test] + fn a_second_lock_on_the_same_file_is_refused_until_released() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("botloftd.lock"); + let first = InstanceLock::acquire(&path).expect("first lock"); + assert!(matches!( + InstanceLock::acquire(&path), + Err(LockError::AlreadyRunning) + )); + drop(first); + InstanceLock::acquire(&path).expect("lock after release"); + } +} diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs new file mode 100644 index 00000000..bf65cfca --- /dev/null +++ b/crates/botloftd/src/platform/unix.rs @@ -0,0 +1,53 @@ +//! Unix fallbacks, so the daemon builds and its tests run off Windows. + +use std::ffi::OsString; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +/// `0700` for folders, `0600` for files. +pub fn restrict_to_current_user(path: &Path) -> io::Result<()> { + let mode = if path.is_dir() { 0o700 } else { 0o600 }; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) +} + +pub async fn shutdown_signal() { + use tokio::signal::unix::{SignalKind, signal}; + + let terminate = async { + match signal(SignalKind::terminate()) { + Ok(mut sig) => { + sig.recv().await; + } + Err(_) => std::future::pending::<()>().await, + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + () = terminate => {} + } +} + +/// No job objects here; the runtime kills the child directly. +#[derive(Debug)] +pub struct ProcessJob; + +impl ProcessJob { + pub fn new() -> io::Result { + Ok(Self) + } + + pub fn terminate(&self) -> io::Result<()> { + Ok(()) + } +} + +/// The daemon's environment without Claude Code session variables. +pub fn user_environment() -> io::Result> { + Ok(std::env::vars_os() + .filter(|(name, _)| { + let name = name.to_string_lossy(); + name != "CLAUDECODE" && !name.starts_with("CLAUDE_CODE_") + }) + .collect()) +} diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs new file mode 100644 index 00000000..ade501df --- /dev/null +++ b/crates/botloftd/src/platform/windows.rs @@ -0,0 +1,155 @@ +//! Windows ACLs, Job Objects, the user environment and console signals. + +mod env; +mod job; + +pub use env::user_environment; +pub use job::ProcessJob; + +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::path::Path; + +use windows::Win32::Foundation::{ + CloseHandle, ERROR_SUCCESS, GENERIC_ALL, HANDLE, HLOCAL, LocalFree, WIN32_ERROR, +}; +use windows::Win32::Security::Authorization::{ + EXPLICIT_ACCESS_W, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, + SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, +}; +use windows::Win32::Security::{ + ACL, DACL_SECURITY_INFORMATION, GetTokenInformation, NO_INHERITANCE, + PROTECTED_DACL_SECURITY_INFORMATION, PSID, SUB_CONTAINERS_AND_OBJECTS_INHERIT, TOKEN_QUERY, + TOKEN_USER, TokenUser, +}; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +use windows::core::{PCWSTR, PWSTR}; + +/// Replaces the DACL of `path` with a single ACE granting full control to +/// the current user, and turns inheritance off (spec 13). Folders pass the +/// ACE on to what is created inside them. +pub fn restrict_to_current_user(path: &Path) -> io::Result<()> { + let user = CurrentUser::query()?; + let inheritance = if path.is_dir() { + SUB_CONTAINERS_AND_OBJECTS_INHERIT + } else { + NO_INHERITANCE + }; + let access = EXPLICIT_ACCESS_W { + grfAccessPermissions: GENERIC_ALL.0, + grfAccessMode: SET_ACCESS, + grfInheritance: inheritance, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: PWSTR(user.sid().0.cast()), + }, + }; + + let mut acl: *mut ACL = std::ptr::null_mut(); + // SAFETY: `access` points at a SID owned by `user`, alive for the call. + check(unsafe { SetEntriesInAclW(Some(&[access]), None, &mut acl) })?; + let acl = LocalBox(acl.cast()); + + let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + // SAFETY: `wide` is NUL-terminated and `acl` stays alive until return. + check(unsafe { + SetNamedSecurityInfoW( + PCWSTR(wide.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + None, + None, + Some(acl.0.cast::().cast_const()), + None, + ) + }) +} + +fn check(status: WIN32_ERROR) -> io::Result<()> { + if status == ERROR_SUCCESS { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(status.0 as i32)) + } +} + +/// Memory returned by Win32 that must go back through `LocalFree`. +struct LocalBox(*mut core::ffi::c_void); + +impl Drop for LocalBox { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the pointer came from a Win32 allocation freed with LocalFree. + unsafe { LocalFree(Some(HLOCAL(self.0))) }; + } + } +} + +/// The `TOKEN_USER` of the current process, which holds the user's SID. +pub(crate) struct CurrentUser { + buf: Vec, +} + +impl CurrentUser { + pub(crate) fn query() -> io::Result { + let mut token = HANDLE::default(); + // SAFETY: the pseudo handle of the current process is always valid. + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }?; + let token = OwnedHandle(token); + + let mut len = 0u32; + // SAFETY: a size query with no buffer; failing with "insufficient + // buffer" is the expected outcome and `len` receives the size. + let _ = unsafe { GetTokenInformation(token.0, TokenUser, None, 0, &mut len) }; + let mut buf = vec![0u64; (len as usize).div_ceil(8)]; + // SAFETY: `buf` is at least `len` bytes and 8-byte aligned. + unsafe { + GetTokenInformation( + token.0, + TokenUser, + Some(buf.as_mut_ptr().cast()), + len, + &mut len, + ) + }?; + Ok(Self { buf }) + } + + pub(crate) fn sid(&self) -> PSID { + // SAFETY: `buf` was filled by GetTokenInformation(TokenUser). + unsafe { (*self.buf.as_ptr().cast::()).User.Sid } + } +} + +struct OwnedHandle(HANDLE); + +impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: the handle was opened by us and is closed once. + let _ = unsafe { CloseHandle(self.0) }; + } +} + +pub async fn shutdown_signal() { + use tokio::signal::windows::{ctrl_close, ctrl_logoff, ctrl_shutdown}; + + async fn wait(signal: io::Result, recv: impl AsyncFnOnce(T)) { + match signal { + Ok(sig) => recv(sig).await, + Err(_) => std::future::pending::<()>().await, + } + } + + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + () = wait(ctrl_close(), async |mut s| { s.recv().await; }) => {} + () = wait(ctrl_logoff(), async |mut s| { s.recv().await; }) => {} + () = wait(ctrl_shutdown(), async |mut s| { s.recv().await; }) => {} + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/botloftd/src/platform/windows/env.rs b/crates/botloftd/src/platform/windows/env.rs new file mode 100644 index 00000000..d00c203c --- /dev/null +++ b/crates/botloftd/src/platform/windows/env.rs @@ -0,0 +1,67 @@ +//! The environment a fresh logon of the current user would get. + +use std::ffi::{OsString, c_void}; +use std::io; +use std::os::windows::ffi::OsStringExt; + +use windows::Win32::Foundation::HANDLE; +use windows::Win32::Security::{TOKEN_DUPLICATE, TOKEN_IMPERSONATE, TOKEN_QUERY}; +use windows::Win32::System::Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock}; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +use super::OwnedHandle; + +/// System and user variables from the registry, as `CreateEnvironmentBlock` +/// builds them for a new logon, without anything the daemon's own parent +/// process added. +pub fn user_environment() -> io::Result> { + let mut token = HANDLE::default(); + // SAFETY: the pseudo handle of the current process is always valid. + unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_IMPERSONATE, + &mut token, + ) + }?; + let token = OwnedHandle(token); + + let mut block: *mut c_void = std::ptr::null_mut(); + // SAFETY: `block` receives a buffer freed below with DestroyEnvironmentBlock. + unsafe { CreateEnvironmentBlock(&mut block, Some(token.0), false) }?; + // SAFETY: the block is a sequence of NUL-terminated UTF-16 strings that + // ends with an empty one. + let vars = unsafe { parse_block(block.cast::()) }; + // SAFETY: `block` came from CreateEnvironmentBlock. + let _ = unsafe { DestroyEnvironmentBlock(block) }; + Ok(vars) +} + +/// # Safety +/// `block` must point at a valid environment block. +unsafe fn parse_block(block: *const u16) -> Vec<(OsString, OsString)> { + let mut vars = Vec::new(); + let mut cursor = block; + loop { + let mut len = 0; + // SAFETY: every string in the block is NUL-terminated. + while unsafe { *cursor.add(len) } != 0 { + len += 1; + } + if len == 0 { + return vars; + } + // SAFETY: `len` units were just read from this string. + let entry = unsafe { std::slice::from_raw_parts(cursor, len) }; + // Skip the leading `=` of hidden per-drive entries like `=C:=C:\`. + if let Some(eq) = entry.iter().skip(1).position(|&c| c == u16::from(b'=')) { + let eq = eq + 1; + vars.push(( + OsString::from_wide(&entry[..eq]), + OsString::from_wide(&entry[eq + 1..]), + )); + } + // SAFETY: move past this string and its NUL. + cursor = unsafe { cursor.add(len + 1) }; + } +} diff --git a/crates/botloftd/src/platform/windows/job.rs b/crates/botloftd/src/platform/windows/job.rs new file mode 100644 index 00000000..c71d1ec2 --- /dev/null +++ b/crates/botloftd/src/platform/windows/job.rs @@ -0,0 +1,63 @@ +//! One Job Object per bot process (spec 7.3). With KILL_ON_JOB_CLOSE, the +//! bot and every process it started die when the job handle closes: when +//! the daemon stops the bot, and also when the daemon itself dies. + +use std::io; +use std::os::windows::io::RawHandle; + +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, +}; +use windows::core::PCWSTR; + +#[derive(Debug)] +pub struct ProcessJob(HANDLE); + +// SAFETY: a job handle can be used and closed from any thread. +unsafe impl Send for ProcessJob {} +// SAFETY: the Win32 job functions used here are thread-safe. +unsafe impl Sync for ProcessJob {} + +impl ProcessJob { + pub fn new() -> io::Result { + // SAFETY: anonymous job with default security. + let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()) }?; + let job = Self(handle); + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: `limits` is the struct this information class expects. + unsafe { + SetInformationJobObject( + job.0, + JobObjectExtendedLimitInformation, + (&raw const limits).cast(), + size_of::() as u32, + ) + }?; + Ok(job) + } + + /// Puts a process (and whatever it starts from now on) in the job. + pub fn assign(&self, process: RawHandle) -> io::Result<()> { + // SAFETY: `process` is a live process handle owned by the caller. + unsafe { AssignProcessToJobObject(self.0, HANDLE(process)) }?; + Ok(()) + } + + /// Kills every process in the job. + pub fn terminate(&self) -> io::Result<()> { + // SAFETY: the handle is valid until drop. + unsafe { TerminateJobObject(self.0, 1) }?; + Ok(()) + } +} + +impl Drop for ProcessJob { + fn drop(&mut self) { + // SAFETY: closed exactly once; KILL_ON_JOB_CLOSE ends the processes. + let _ = unsafe { CloseHandle(self.0) }; + } +} diff --git a/crates/botloftd/src/platform/windows/tests.rs b/crates/botloftd/src/platform/windows/tests.rs new file mode 100644 index 00000000..078eb405 --- /dev/null +++ b/crates/botloftd/src/platform/windows/tests.rs @@ -0,0 +1,85 @@ +use std::ffi::c_void; + +use windows::Win32::Security::Authorization::GetNamedSecurityInfoW; +use windows::Win32::Security::{ + ACCESS_ALLOWED_ACE, EqualSid, GetAce, GetSecurityDescriptorControl, PSECURITY_DESCRIPTOR, + SE_DACL_PROTECTED, +}; + +use super::*; + +struct Dacl { + ace_count: u16, + protected: bool, + all_aces_are_current_user: bool, +} + +fn read_dacl(path: &Path) -> Dacl { + let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + // SAFETY: out-pointers are valid; the descriptor is freed by LocalBox. + check(unsafe { + GetNamedSecurityInfoW( + PCWSTR(wide.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(&mut dacl), + None, + &mut descriptor, + ) + }) + .expect("GetNamedSecurityInfoW"); + let _descriptor = LocalBox(descriptor.0); + + let mut control = 0u16; + let mut revision = 0u32; + // SAFETY: `descriptor` is valid until `_descriptor` drops. + unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } + .expect("GetSecurityDescriptorControl"); + + // SAFETY: `dacl` points into `descriptor`. + let ace_count = unsafe { (*dacl).AceCount }; + let user = CurrentUser::query().expect("current user"); + let all_aces_are_current_user = (0..u32::from(ace_count)).all(|index| { + let mut ace: *mut c_void = std::ptr::null_mut(); + // SAFETY: `index` is below the ACL's ACE count. + unsafe { GetAce(dacl, index, &mut ace) }.expect("GetAce"); + // Only access-allowed ACEs are expected; the SID starts at `SidStart`. + let ace = ace.cast::(); + let sid = PSID(unsafe { (&raw mut (*ace).SidStart).cast() }); + // SAFETY: both SIDs are valid for the duration of the call. + unsafe { EqualSid(sid, user.sid()) }.is_ok() + }); + + Dacl { + ace_count, + protected: control & SE_DACL_PROTECTED.0 != 0, + all_aces_are_current_user, + } +} + +#[test] +fn folders_and_files_are_restricted_to_the_current_user() { + let dir = tempfile::tempdir().expect("tempdir"); + let secrets = dir.path().join("secrets"); + std::fs::create_dir(&secrets).expect("mkdir"); + restrict_to_current_user(&secrets).expect("restrict folder"); + + // Windows stores an inheritable GENERIC_ALL entry as two ACEs (one for + // the folder, one inherit-only for its children); both name the user. + let folder = read_dacl(&secrets); + assert!(folder.ace_count >= 1); + assert!(folder.protected, "inheritance must be off"); + assert!(folder.all_aces_are_current_user); + + let file = secrets.join("owner.token"); + std::fs::write(&file, "x").expect("write"); + restrict_to_current_user(&file).expect("restrict file"); + let file = read_dacl(&file); + assert_eq!(file.ace_count, 1); + assert!(file.protected); + assert!(file.all_aces_are_current_user); +} diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs new file mode 100644 index 00000000..0d8052e3 --- /dev/null +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -0,0 +1,48 @@ +//! Routes an authenticated request to its service operation. + +use botloft_core::protocol::{error_code, method}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use super::jsonrpc::{RpcError, empty_params}; +use crate::service::{self, ApiResult, bots, crews, terminal}; +use crate::state::Daemon; + +pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result { + match name { + method::SYSTEM_STATUS => reply(Ok(service::status(daemon))), + method::CREWS_LIST => reply(crews::list(daemon)), + method::CREWS_CREATE => reply(crews::create(daemon, parse(params)?)), + method::CREWS_RENAME => reply(crews::rename(daemon, parse(params)?)), + method::CREWS_SET_PAUSED => reply(crews::set_paused(daemon, parse(params)?)), + method::CREWS_ARCHIVE => reply(crews::archive(daemon, parse(params)?)), + method::BOTS_LIST => reply(bots::list(daemon, parse(params)?)), + method::BOTS_CREATE => reply(bots::create(daemon, parse(params)?)), + method::BOTS_UPDATE => reply(bots::update(daemon, parse(params)?)), + method::BOTS_SET_PAUSED => reply(bots::set_paused(daemon, parse(params)?)), + method::BOTS_ARCHIVE => reply(bots::archive(daemon, parse(params)?)), + method::BOTS_RESTART => reply(bots::restart(daemon, parse(params)?)), + method::TERMINAL_WRITE => reply(terminal::write(daemon, parse(params)?)), + method::TERMINAL_RESIZE => reply(terminal::resize(daemon, parse(params)?)), + method::SESSION_HELLO => Err(RpcError::new( + error_code::CONFLICT, + "this connection is already authenticated", + )), + other => Err(RpcError::new( + error_code::METHOD_NOT_FOUND, + format!("unknown method {other}"), + )), + } +} + +pub(super) fn parse(params: Option) -> Result { + serde_json::from_value(params.unwrap_or_else(empty_params)) + .map_err(|err| RpcError::new(error_code::INVALID_PARAMS, format!("invalid params: {err}"))) +} + +fn reply(result: ApiResult) -> Result { + let value = result.map_err(RpcError::from)?; + serde_json::to_value(value) + .map_err(|err| RpcError::new(error_code::INTERNAL_ERROR, err.to_string())) +} diff --git a/crates/botloftd/src/rpc/jsonrpc.rs b/crates/botloftd/src/rpc/jsonrpc.rs new file mode 100644 index 00000000..18502bc4 --- /dev/null +++ b/crates/botloftd/src/rpc/jsonrpc.rs @@ -0,0 +1,168 @@ +//! JSON-RPC 2.0 framing: parsing requests and writing responses and +//! notifications. + +use botloft_core::protocol::error_code; +use serde_json::{Map, Value, json}; + +use crate::service::ApiError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RpcError { + pub code: i32, + pub message: String, +} + +impl RpcError { + pub fn new(code: i32, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +impl From for RpcError { + fn from(err: ApiError) -> Self { + let code = err.code(); + if code == error_code::INTERNAL_ERROR { + // Store and I/O errors may name paths; they go to the log at + // warn level, never message bodies or tokens. + tracing::warn!(error = %err, source = ?std::error::Error::source(&err), "request failed"); + } + Self::new(code, err.to_string()) + } +} + +/// A well-formed request. `id` is `None` for a client notification, which +/// gets no response. +#[derive(Debug, Clone, PartialEq)] +pub struct Request { + pub id: Option, + pub method: String, + pub params: Option, +} + +/// Parses one WebSocket text frame. On failure returns the id to answer +/// with (`null` when unknown) and the error. +pub fn parse(text: &str) -> Result { + let value: Value = serde_json::from_str(text).map_err(|_| { + ( + Value::Null, + RpcError::new(error_code::PARSE_ERROR, "parse error"), + ) + })?; + let Value::Object(mut object) = value else { + return Err(invalid(Value::Null, "a request must be a JSON object")); + }; + let id = object.remove("id"); + let reply_id = id.clone().unwrap_or(Value::Null); + if !matches!( + id, + None | Some(Value::String(_) | Value::Number(_) | Value::Null) + ) { + return Err(invalid( + Value::Null, + "id must be a string, a number or null", + )); + } + if object.get("jsonrpc") != Some(&Value::String("2.0".to_owned())) { + return Err(invalid(reply_id, "jsonrpc must be \"2.0\"")); + } + let Some(Value::String(method)) = object.remove("method") else { + return Err(invalid(reply_id, "method must be a string")); + }; + let params = object.remove("params"); + if !matches!(params, None | Some(Value::Object(_) | Value::Array(_))) { + return Err(invalid(reply_id, "params must be an object or an array")); + } + Ok(Request { id, method, params }) +} + +fn invalid(id: Value, message: &str) -> (Value, RpcError) { + (id, RpcError::new(error_code::INVALID_REQUEST, message)) +} + +pub fn success(id: &Value, result: Value) -> String { + json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string() +} + +pub fn failure(id: &Value, err: &RpcError) -> String { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": err.code, "message": err.message }, + }) + .to_string() +} + +pub fn notification(method: &str, params: Value) -> String { + json!({ "jsonrpc": "2.0", "method": method, "params": params }).to_string() +} + +/// Params for methods that take none. +pub fn empty_params() -> Value { + Value::Object(Map::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_request() { + let req = parse(r#"{"jsonrpc":"2.0","id":7,"method":"crews.list"}"#).expect("parse"); + assert_eq!(req.id, Some(json!(7))); + assert_eq!(req.method, "crews.list"); + assert_eq!(req.params, None); + } + + #[test] + fn a_request_without_id_is_a_notification() { + let req = parse(r#"{"jsonrpc":"2.0","method":"x","params":{}}"#).expect("parse"); + assert_eq!(req.id, None); + } + + #[test] + fn rejects_malformed_frames_with_standard_codes() { + let code = |text: &str| parse(text).map(|_| 0).unwrap_or_else(|(_, e)| e.code); + assert_eq!(code("{"), error_code::PARSE_ERROR); + assert_eq!(code("[1]"), error_code::INVALID_REQUEST); + assert_eq!( + code(r#"{"id":1,"method":"x"}"#), + error_code::INVALID_REQUEST + ); + assert_eq!( + code(r#"{"jsonrpc":"2.0","id":{},"method":"x"}"#), + error_code::INVALID_REQUEST + ); + assert_eq!( + code(r#"{"jsonrpc":"2.0","id":1,"method":2}"#), + error_code::INVALID_REQUEST + ); + assert_eq!( + code(r#"{"jsonrpc":"2.0","id":1,"method":"x","params":3}"#), + error_code::INVALID_REQUEST + ); + } + + #[test] + fn errors_keep_the_request_id_when_known() { + let (id, _) = parse(r#"{"jsonrpc":"1.0","id":"a","method":"x"}"#).expect_err("invalid"); + assert_eq!(id, json!("a")); + } + + #[test] + fn writes_responses_and_notifications() { + let ok: Value = serde_json::from_str(&success(&json!(1), json!([]))).expect("json"); + assert_eq!(ok, json!({"jsonrpc":"2.0","id":1,"result":[]})); + let err = RpcError::new(-32002, "missing"); + let failed: Value = serde_json::from_str(&failure(&json!(1), &err)).expect("json"); + assert_eq!(failed["error"], json!({"code":-32002,"message":"missing"})); + let note: Value = + serde_json::from_str(¬ification("crew.changed", json!({}))).expect("json"); + assert_eq!( + note, + json!({"jsonrpc":"2.0","method":"crew.changed","params":{}}) + ); + } +} diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs new file mode 100644 index 00000000..b2f80fa6 --- /dev/null +++ b/crates/botloftd/src/rpc/mod.rs @@ -0,0 +1,172 @@ +//! The app's connection: one WebSocket per client, JSON-RPC 2.0 over text +//! frames (spec 11). The first request must be `session.hello`. + +mod dispatch; +pub mod jsonrpc; +mod terminal; + +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::ws::{Message, WebSocket}; +use botloft_core::protocol::{ + HelloParams, HelloResult, PROTOCOL_VERSION, error_code, method, notification, +}; +use futures_util::stream::SplitStream; +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tokio::sync::{broadcast, mpsc}; +use tracing::{debug, warn}; + +use self::jsonrpc::RpcError; +use crate::state::{Daemon, Event}; + +/// How long a new connection may take to send `session.hello`. +const HELLO_TIMEOUT: Duration = Duration::from_secs(10); +/// Outgoing frames queued per connection. +const OUTBOX: usize = 256; + +pub async fn serve_connection(socket: WebSocket, daemon: Arc) { + let (mut sink, mut stream) = socket.split(); + let (outbox, mut queued) = mpsc::channel::(OUTBOX); + let writer = tokio::spawn(async move { + while let Some(text) = queued.recv().await { + if sink.send(Message::Text(text.into())).await.is_err() { + return; + } + } + let _ = sink.send(Message::Close(None)).await; + }); + + // Subscribe before answering hello so no change slips in between. + let events = daemon.subscribe(); + if authenticate(&mut stream, &outbox, &daemon).await { + let mut streams = terminal::Streams::default(); + run(stream, &outbox, events, &daemon, &mut streams).await; + streams.stop_all(); + } + drop(outbox); + let _ = writer.await; +} + +async fn authenticate( + stream: &mut SplitStream, + outbox: &mpsc::Sender, + daemon: &Daemon, +) -> bool { + let Ok(Some(Ok(Message::Text(text)))) = + tokio::time::timeout(HELLO_TIMEOUT, stream.next()).await + else { + return false; + }; + let request = match jsonrpc::parse(&text) { + Ok(request) => request, + Err((id, err)) => { + let _ = outbox.send(jsonrpc::failure(&id, &err)).await; + return false; + } + }; + let id = request.id.clone().unwrap_or(Value::Null); + let result = hello(daemon, request); + let frame = match &result { + Ok(ok) => jsonrpc::success(&id, serde_json::to_value(ok).unwrap_or(Value::Null)), + Err(err) => jsonrpc::failure(&id, err), + }; + let _ = outbox.send(frame).await; + result.is_ok() +} + +fn hello(daemon: &Daemon, request: jsonrpc::Request) -> Result { + let unauthenticated = |message: &str| RpcError::new(error_code::NOT_AUTHENTICATED, message); + if request.method != method::SESSION_HELLO { + return Err(unauthenticated("call session.hello first")); + } + let params: HelloParams = serde_json::from_value(request.params.unwrap_or(Value::Null)) + .map_err(|_| unauthenticated("session.hello needs token, client and protocol"))?; + if !daemon.is_owner_token(¶ms.token) { + warn!(client = %params.client.name, "rejected a connection with a wrong token"); + return Err(unauthenticated("invalid token")); + } + if params.protocol != PROTOCOL_VERSION { + return Err(RpcError::new( + error_code::VALIDATION, + format!( + "protocol {} is not supported; this daemon speaks {PROTOCOL_VERSION}", + params.protocol + ), + )); + } + debug!(client = %params.client.name, version = %params.client.version, "app connected"); + Ok(HelloResult { + daemon_version: env!("CARGO_PKG_VERSION").to_owned(), + protocol: PROTOCOL_VERSION, + }) +} + +async fn run( + mut stream: SplitStream, + outbox: &mpsc::Sender, + mut events: broadcast::Receiver, + daemon: &Daemon, + streams: &mut terminal::Streams, +) { + loop { + let frame = tokio::select! { + message = stream.next() => match message { + Some(Ok(Message::Text(text))) => handle(daemon, &text, outbox, streams).await, + Some(Ok(Message::Close(_)) | Err(_)) | None => return, + Some(Ok(_)) => None, + }, + event = events.recv() => match event { + Ok(event) => Some(to_notification(&event)), + Err(broadcast::error::RecvError::Lagged(skipped)) => { + // The client's view is stale; it reloads on reconnect. + warn!(skipped, "closing an app connection that fell behind"); + return; + } + Err(broadcast::error::RecvError::Closed) => return, + }, + }; + if let Some(frame) = frame + && outbox.send(frame).await.is_err() + { + return; + } + } +} + +async fn handle( + daemon: &Daemon, + text: &str, + outbox: &mpsc::Sender, + streams: &mut terminal::Streams, +) -> Option { + let request = match jsonrpc::parse(text) { + Ok(request) => request, + Err((id, err)) => return Some(jsonrpc::failure(&id, &err)), + }; + debug!(method = %request.method, "rpc request"); + let id = request.id.clone(); + let result = match request.method.as_str() { + method::TERMINAL_ATTACH => { + // The response must go out before the first terminal.data. + return terminal::attach(daemon, request, outbox, streams).await; + } + method::TERMINAL_DETACH => terminal::detach(request.params, streams), + _ => dispatch::dispatch(daemon, &request.method, request.params), + }; + let id = id?; + Some(match result { + Ok(value) => jsonrpc::success(&id, value), + Err(err) => jsonrpc::failure(&id, &err), + }) +} + +fn to_notification(event: &Event) -> String { + let (name, params) = match event { + Event::CrewChanged(crew) => (notification::CREW_CHANGED, serde_json::to_value(crew)), + Event::BotChanged(bot) => (notification::BOT_CHANGED, serde_json::to_value(bot)), + Event::BotState(state) => (notification::BOT_STATE, serde_json::to_value(state)), + }; + jsonrpc::notification(name, params.unwrap_or(Value::Null)) +} diff --git a/crates/botloftd/src/rpc/terminal.rs b/crates/botloftd/src/rpc/terminal.rs new file mode 100644 index 00000000..39c74e27 --- /dev/null +++ b/crates/botloftd/src/rpc/terminal.rs @@ -0,0 +1,121 @@ +//! Streaming terminal output to one connection (spec 8). + +use std::collections::HashMap; + +use botloft_core::ids::BotId; +use botloft_core::protocol::{ + BotIdParams, TerminalAttachParams, TerminalAttachResult, TerminalData, notification, +}; +use serde_json::Value; +use tokio::sync::{broadcast, mpsc}; +use tokio::task::JoinHandle; + +use super::dispatch::parse; +use super::jsonrpc::{self, Request, RpcError}; +use crate::service; +use crate::state::Daemon; +use crate::terminal::Attachment; + +/// The terminals this connection watches, one forwarding task each. +#[derive(Default)] +pub struct Streams { + tasks: HashMap>, +} + +impl Streams { + fn replace(&mut self, bot: BotId, task: JoinHandle<()>) { + if let Some(old) = self.tasks.insert(bot, task) { + old.abort(); + } + } + + pub fn stop_all(&mut self) { + for (_, task) in self.tasks.drain() { + task.abort(); + } + } +} + +/// Answers `terminal.attach` and then streams the replay and live output. +/// Returns a frame only when there is no stream to start. +pub async fn attach( + daemon: &Daemon, + request: Request, + outbox: &mpsc::Sender, + streams: &mut Streams, +) -> Option { + let id = request.id.unwrap_or(Value::Null); + let attached = parse::(request.params).and_then(|params| { + let bot = params.bot_id.clone(); + service::terminal::attach(daemon, params) + .map(|attachment| (bot, attachment)) + .map_err(RpcError::from) + }); + let (bot, attachment) = match attached { + Ok(attached) => attached, + Err(err) => return Some(jsonrpc::failure(&id, &err)), + }; + let point = attachment.point; + let result = TerminalAttachResult { + generation: point.generation, + offset: point.offset, + reset: point.reset, + }; + let frame = jsonrpc::success(&id, serde_json::to_value(result).unwrap_or(Value::Null)); + if outbox.send(frame).await.is_err() { + return None; + } + let task = tokio::spawn(forward(bot.clone(), attachment, outbox.clone())); + streams.replace(bot, task); + None +} + +pub fn detach(params: Option, streams: &mut Streams) -> Result { + let params: BotIdParams = parse(params)?; + if let Some(task) = streams.tasks.remove(¶ms.bot_id) { + task.abort(); + } + Ok(Value::Null) +} + +/// Sends the replay, then every new chunk. A client that falls behind sees +/// the offset jump and attaches again from what it has. +async fn forward(bot: BotId, attachment: Attachment, outbox: mpsc::Sender) { + let Attachment { + point, + replay, + mut live, + } = attachment; + if !replay.is_empty() + && outbox + .send(data_frame(&bot, point.generation, point.offset, &replay)) + .await + .is_err() + { + return; + } + loop { + let chunk = match live.recv().await { + Ok(chunk) => chunk, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return, + }; + let frame = data_frame(&bot, chunk.generation, chunk.offset, &chunk.data); + if outbox.send(frame).await.is_err() { + return; + } + } +} + +fn data_frame(bot: &BotId, generation: u64, offset: u64, data: &[u8]) -> String { + let params = TerminalData { + bot_id: bot.clone(), + generation, + offset, + data: service::terminal::encode(data), + }; + jsonrpc::notification( + notification::TERMINAL_DATA, + serde_json::to_value(params).unwrap_or(Value::Null), + ) +} diff --git a/crates/botloftd/src/runtime/claude.rs b/crates/botloftd/src/runtime/claude.rs new file mode 100644 index 00000000..df9ec3d5 --- /dev/null +++ b/crates/botloftd/src/runtime/claude.rs @@ -0,0 +1,194 @@ +//! Finding and checking the Claude Code executable (spec 7.4). + +use std::ffi::OsString; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// First version with the inbox as a named pipe on native Windows. +pub const MIN_VERSION: (u32, u32, u32) = (2, 1, 234); +const PROBE_TIMEOUT: Duration = Duration::from_secs(15); + +/// A usable Claude Code executable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Claude { + pub path: PathBuf, + pub version: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum ClaudeError { + #[error("Claude Code was not found in PATH; install it or set claude_path in config.toml")] + NotFound, + #[error( + "{0} is the npm launcher; Botloft runs the native claude.exe. Install Claude Code with the native installer or point claude_path at the .exe" + )] + Launcher(PathBuf), + #[error("cannot run {path}: {source}")] + Run { path: PathBuf, source: io::Error }, + #[error("cannot read the user environment: {0}")] + Environment(io::Error), + #[error("`claude --version` did not report a version")] + UnknownVersion, + #[error("Claude Code {found} is too old; Botloft needs {needed} or later")] + TooOld { found: String, needed: String }, +} + +/// The configured path, or the first `claude` executable on `PATH`. +pub fn locate(configured: &str, env: &[(OsString, OsString)]) -> Result { + let configured = configured.trim(); + if !configured.is_empty() { + return not_a_launcher(PathBuf::from(configured)); + } + let path_var = env + .iter() + .find(|(name, _)| name.to_string_lossy().eq_ignore_ascii_case("PATH")) + .map(|(_, value)| value.clone()) + .unwrap_or_default(); + for dir in path_entries(&path_var.to_string_lossy()) { + for name in CANDIDATES { + let candidate = dir.join(name); + if candidate.is_file() { + return not_a_launcher(candidate); + } + } + } + Err(ClaudeError::NotFound) +} + +/// Splits `PATH` the way Windows searches it: on `;` only. A stray quote in +/// one entry (common on real systems) must not swallow the rest, which +/// `std::env::split_paths` does because it treats quotes as grouping. +fn path_entries(path: &str) -> impl Iterator + '_ { + let separator = if cfg!(windows) { ';' } else { ':' }; + path.split(separator) + .map(|entry| entry.trim().trim_matches('"')) + .filter(|entry| !entry.is_empty()) + .map(PathBuf::from) +} + +#[cfg(windows)] +const CANDIDATES: &[&str] = &["claude.exe", "claude.cmd"]; +#[cfg(not(windows))] +const CANDIDATES: &[&str] = &["claude"]; + +/// `claude.cmd` needs `cmd.exe` in between, which mangles the quoting of +/// arguments passed through the pseudo terminal; only real executables run. +fn not_a_launcher(path: PathBuf) -> Result { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()); + match ext.as_deref() { + Some("cmd" | "bat" | "ps1") => Err(ClaudeError::Launcher(path)), + _ => Ok(path), + } +} + +/// Runs `claude --version` and checks it against [`MIN_VERSION`]. +pub fn probe(path: &Path, env: &[(OsString, OsString)]) -> Result { + let run_error = |source| ClaudeError::Run { + path: path.to_owned(), + source, + }; + let mut command = Command::new(path); + command + .arg("--version") + .env_clear() + .envs(env.iter().map(|(k, v)| (k, v))) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + let mut child = command.spawn().map_err(run_error)?; + let started = Instant::now(); + while child.try_wait().map_err(run_error)?.is_none() { + if started.elapsed() > PROBE_TIMEOUT { + let _ = child.kill(); + return Err(run_error(io::Error::new( + io::ErrorKind::TimedOut, + "`claude --version` did not finish", + ))); + } + std::thread::sleep(Duration::from_millis(50)); + } + let output = child.wait_with_output().map_err(run_error)?; + let text = String::from_utf8_lossy(&output.stdout); + let (version, parsed) = parse_version(&text).ok_or(ClaudeError::UnknownVersion)?; + if parsed < MIN_VERSION { + let (major, minor, patch) = MIN_VERSION; + return Err(ClaudeError::TooOld { + found: version, + needed: format!("{major}.{minor}.{patch}"), + }); + } + Ok(Claude { + path: path.to_owned(), + version, + }) +} + +/// Reads `2.1.283` from output such as `2.1.283 (Claude Code)`. +pub fn parse_version(text: &str) -> Option<(String, (u32, u32, u32))> { + let token = text.split_whitespace().next()?; + let mut parts = token.split('.').map(|part| part.parse::().ok()); + let parsed = (parts.next()??, parts.next()??, parts.next()??); + Some((token.to_owned(), parsed)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_version_line() { + assert_eq!( + parse_version("2.1.283 (Claude Code)\n"), + Some(("2.1.283".to_owned(), (2, 1, 283))) + ); + assert_eq!(parse_version("Claude Code"), None); + assert_eq!(parse_version(""), None); + assert!(parse_version("2.1.74 (Claude Code)").is_some_and(|(_, v)| v < MIN_VERSION)); + } + + #[test] + fn refuses_npm_launchers() { + assert!(matches!( + locate(r"C:\npm\claude.cmd", &[]), + Err(ClaudeError::Launcher(_)) + )); + assert_eq!( + locate(r"C:\bin\claude.exe", &[]).expect("configured path"), + PathBuf::from(r"C:\bin\claude.exe") + ); + } + + #[cfg(windows)] + #[test] + fn a_stray_quote_in_path_does_not_hide_later_entries() { + let path = r#"C:\A";C:\B\;;"C:\Quoted Dir";C:\Users\me\.local\bin"#; + let entries: Vec = path_entries(path).collect(); + let expected = [ + r"C:\A", + r"C:\B\", + r"C:\Quoted Dir", + r"C:\Users\me\.local\bin", + ]; + assert_eq!(entries, expected.map(PathBuf::from)); + } + + #[test] + fn searches_the_given_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let exe = dir.path().join(CANDIDATES[0]); + std::fs::write(&exe, b"").expect("fake exe"); + let env = vec![(OsString::from("Path"), dir.path().as_os_str().to_owned())]; + assert_eq!(locate("", &env).expect("found"), exe); + assert!(matches!(locate("", &[]), Err(ClaudeError::NotFound))); + } +} diff --git a/crates/botloftd/src/runtime/fake.rs b/crates/botloftd/src/runtime/fake.rs new file mode 100644 index 00000000..47633b4e --- /dev/null +++ b/crates/botloftd/src/runtime/fake.rs @@ -0,0 +1,178 @@ +//! A runtime that starts nothing (spec 1.6). Each spawn returns a +//! [`FakeProcess`] the test drives by hand: it emits output, exits and +//! records what the daemon wrote to it. + +use std::io; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use tokio::sync::{Notify, mpsc}; + +use super::{EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SpawnSpec, TermSize}; + +#[derive(Clone, Default)] +pub struct FakeRuntime { + state: Arc>, + spawned: Arc, +} + +#[derive(Default)] +struct State { + processes: Vec, + failures: usize, +} + +#[derive(Clone)] +pub struct FakeProcess { + pub spec: SpawnSpec, + events: mpsc::Sender, + input: Arc>>, + sizes: Arc>>, + killed: Arc, +} + +impl FakeRuntime { + pub fn new() -> Self { + Self::default() + } + + /// Every process spawned so far, oldest first. + pub fn processes(&self) -> Vec { + lock(&self.state).processes.clone() + } + + /// The `n`-th spawned process (1-based), waiting up to 5 s for it. + pub async fn process(&self, n: usize) -> FakeProcess { + let wait = async { + loop { + // Registered before the check, so a spawn in between still wakes us. + let notified = self.spawned.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if let Some(process) = lock(&self.state).processes.get(n - 1) { + return process.clone(); + } + notified.await; + } + }; + tokio::time::timeout(Duration::from_secs(5), wait) + .await + .unwrap_or_else(|_| panic!("process {n} was never spawned")) + } + + /// Makes the next spawn fail, as a missing executable would. + pub fn fail_next_spawn(&self) { + lock(&self.state).failures += 1; + } +} + +impl Runtime for FakeRuntime { + fn spawn(&self, spec: SpawnSpec) -> io::Result { + let mut state = lock(&self.state); + if state.failures > 0 { + state.failures -= 1; + return Err(io::Error::new( + io::ErrorKind::NotFound, + "fake spawn failure", + )); + } + let (events, events_rx) = mpsc::channel(EVENT_BUFFER); + let process = FakeProcess { + spec, + events: events.clone(), + input: Arc::default(), + sizes: Arc::default(), + killed: Arc::default(), + }; + let control = FakeControl { + events, + input: Arc::clone(&process.input), + sizes: Arc::clone(&process.sizes), + killed: Arc::clone(&process.killed), + }; + state.processes.push(process); + drop(state); + self.spawned.notify_waiters(); + Ok(Process { + pid: None, + events: events_rx, + control: Box::new(control), + }) + } +} + +impl FakeProcess { + pub async fn output(&self, data: &[u8]) { + let _ = self + .events + .send(ProcessEvent::Output(Bytes::copy_from_slice(data))) + .await; + } + + pub async fn exit(&self, code: u32) { + let _ = self.events.send(ProcessEvent::Exited(Some(code))).await; + } + + pub fn input(&self) -> Vec { + lock(&self.input).clone() + } + + pub fn sizes(&self) -> Vec { + lock(&self.sizes).clone() + } + + pub fn killed(&self) -> bool { + self.killed.load(Ordering::SeqCst) + } + + pub fn args(&self) -> Vec { + self.spec + .args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + } + + pub fn env(&self, name: &str) -> Option { + self.spec + .env + .iter() + .find(|(key, _)| key.to_string_lossy().eq_ignore_ascii_case(name)) + .map(|(_, value)| value.to_string_lossy().into_owned()) + } +} + +struct FakeControl { + events: mpsc::Sender, + input: Arc>>, + sizes: Arc>>, + killed: Arc, +} + +impl ProcessControl for FakeControl { + fn write(&self, data: Bytes) -> io::Result<()> { + lock(&self.input).extend_from_slice(&data); + Ok(()) + } + + fn resize(&self, size: TermSize) -> io::Result<()> { + lock(&self.sizes).push(size); + Ok(()) + } + + /// Dies like a real process would: the exit event follows. + fn kill(&self) -> io::Result<()> { + if !self.killed.swap(true, Ordering::SeqCst) { + let _ = self.events.try_send(ProcessEvent::Exited(None)); + } + Ok(()) + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/crates/botloftd/src/runtime/mod.rs b/crates/botloftd/src/runtime/mod.rs new file mode 100644 index 00000000..249e0c12 --- /dev/null +++ b/crates/botloftd/src/runtime/mod.rs @@ -0,0 +1,74 @@ +//! How bot processes run. [`PtyRuntime`] starts them in a pseudo terminal +//! (ConPTY on Windows); [`fake::FakeRuntime`] implements the same contract +//! for tests, so the supervisor and terminal are tested without Claude. + +pub mod claude; +pub mod fake; +mod pty; + +use std::ffi::OsString; +use std::io; +use std::path::PathBuf; + +use bytes::Bytes; +use tokio::sync::mpsc; + +pub use pty::PtyRuntime; + +/// Terminal size in character cells. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TermSize { + pub cols: u16, + pub rows: u16, +} + +impl Default for TermSize { + /// Used until the app sends `terminal.resize` (spec 7.4). + fn default() -> Self { + Self { + cols: 120, + rows: 32, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpawnSpec { + pub program: PathBuf, + pub args: Vec, + pub cwd: PathBuf, + /// The complete environment. Nothing is inherited from the daemon. + pub env: Vec<(OsString, OsString)>, + pub size: TermSize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProcessEvent { + Output(Bytes), + /// Last event. The exit code, when the OS reported one. + Exited(Option), +} + +/// Handle to a running process. Dropping it kills the process tree. +pub trait ProcessControl: Send + Sync { + /// Queues bytes for the process's input without blocking. + fn write(&self, data: Bytes) -> io::Result<()>; + fn resize(&self, size: TermSize) -> io::Result<()>; + /// Kills the process and everything it started. + fn kill(&self) -> io::Result<()>; +} + +pub struct Process { + pub pid: Option, + /// Output in order, then one [`ProcessEvent::Exited`]. + pub events: mpsc::Receiver, + pub control: Box, +} + +pub trait Runtime: Send + Sync + 'static { + fn spawn(&self, spec: SpawnSpec) -> io::Result; +} + +/// Capacity of the event channel between a process and the daemon. When it +/// is full, the reader thread waits, which in turn slows the process down. +pub(crate) const EVENT_BUFFER: usize = 64; diff --git a/crates/botloftd/src/runtime/pty.rs b/crates/botloftd/src/runtime/pty.rs new file mode 100644 index 00000000..84345aba --- /dev/null +++ b/crates/botloftd/src/runtime/pty.rs @@ -0,0 +1,161 @@ +//! Pseudo terminal runtime. ConPTY has no handle to poll, so each process +//! gets three threads (spec 8): one reads output, one writes input and one +//! waits for the exit. On Windows the process joins its own Job Object. + +use std::io::{self, Read, Write}; +use std::sync::{Arc, Mutex, mpsc as std_mpsc}; +use std::thread; + +use bytes::Bytes; +use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system}; +use tokio::sync::mpsc; + +use super::{EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SpawnSpec, TermSize}; +use crate::platform::ProcessJob; + +const READ_CHUNK: usize = 32 * 1024; + +pub struct PtyRuntime; + +impl Runtime for PtyRuntime { + fn spawn(&self, spec: SpawnSpec) -> io::Result { + let pair = native_pty_system() + .openpty(pty_size(spec.size)) + .map_err(io::Error::other)?; + let mut command = CommandBuilder::new(&spec.program); + command.args(&spec.args); + command.cwd(&spec.cwd); + command.env_clear(); + for (name, value) in &spec.env { + command.env(name, value); + } + + let job = ProcessJob::new()?; + let mut child = pair + .slave + .spawn_command(command) + .map_err(io::Error::other)?; + // The child holds its own end; keeping ours would stop EOF from arriving. + drop(pair.slave); + let mut killer = child.clone_killer(); + #[cfg(windows)] + if let Some(handle) = child.as_raw_handle() + && let Err(err) = job.assign(handle) + { + let _ = killer.kill(); + return Err(err); + } + + let pid = child.process_id(); + let reader = pair.master.try_clone_reader().map_err(io::Error::other)?; + let writer = pair.master.take_writer().map_err(io::Error::other)?; + let master = Arc::new(Mutex::new(Some(pair.master))); + let (events, events_rx) = mpsc::channel(EVENT_BUFFER); + let (exit_tx, exit_rx) = std_mpsc::channel(); + + let waiter_master = Arc::clone(&master); + thread::Builder::new() + .name("bot-wait".into()) + .spawn(move || { + let code = child.wait().ok().map(|status| status.exit_code()); + let _ = exit_tx.send(code); + // ConPTY keeps the output pipe open until the pseudo console + // closes; dropping the master lets the reader see EOF. + lock(&waiter_master).take(); + })?; + thread::Builder::new() + .name("bot-read".into()) + .spawn(move || read_output(reader, &events, &exit_rx))?; + let (input, input_rx) = std_mpsc::channel::(); + thread::Builder::new() + .name("bot-write".into()) + .spawn(move || write_input(writer, &input_rx))?; + + Ok(Process { + pid, + events: events_rx, + control: Box::new(PtyControl { + input: Mutex::new(input), + master, + killer: Mutex::new(killer), + job, + }), + }) + } +} + +fn read_output( + mut reader: Box, + events: &mpsc::Sender, + exit: &std_mpsc::Receiver>, +) { + let mut buf = vec![0u8; READ_CHUNK]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + let chunk = Bytes::copy_from_slice(&buf[..n]); + if events.blocking_send(ProcessEvent::Output(chunk)).is_err() { + break; + } + } + } + } + let code = exit.recv().ok().flatten(); + let _ = events.blocking_send(ProcessEvent::Exited(code)); +} + +fn write_input(mut writer: Box, input: &std_mpsc::Receiver) { + while let Ok(data) = input.recv() { + if writer + .write_all(&data) + .and_then(|()| writer.flush()) + .is_err() + { + return; + } + } +} + +struct PtyControl { + input: Mutex>, + master: Arc>>>, + killer: Mutex>, + job: ProcessJob, +} + +impl ProcessControl for PtyControl { + fn write(&self, data: Bytes) -> io::Result<()> { + lock(&self.input) + .send(data) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "the process has exited")) + } + + fn resize(&self, size: TermSize) -> io::Result<()> { + match lock(&self.master).as_ref() { + Some(master) => master.resize(pty_size(size)).map_err(io::Error::other), + None => Ok(()), + } + } + + fn kill(&self) -> io::Result<()> { + let job = self.job.terminate(); + let child = lock(&self.killer).kill(); + job.or(child) + } +} + +fn pty_size(size: TermSize) -> PtySize { + PtySize { + rows: size.rows.max(1), + cols: size.cols.max(1), + pixel_width: 0, + pixel_height: 0, + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/crates/botloftd/src/secrets.rs b/crates/botloftd/src/secrets.rs new file mode 100644 index 00000000..e6fa016a --- /dev/null +++ b/crates/botloftd/src/secrets.rs @@ -0,0 +1,124 @@ +//! Tokens (spec 13). The owner token lives in `secrets\owner.token`, +//! readable only by the current user; the daemon keeps just its SHA-256. + +use std::fmt; +use std::io; +use std::path::Path; + +use sha2::{Digest, Sha256}; + +use crate::platform; + +const OWNER_TOKEN_FILE: &str = "owner.token"; +const TOKEN_BYTES: usize = 32; + +/// 32 random bytes as 64 lowercase hex characters. +pub fn random_token() -> io::Result { + let mut bytes = [0u8; TOKEN_BYTES]; + getrandom::fill(&mut bytes).map_err(|err| io::Error::other(err.to_string()))?; + Ok(hex::encode(bytes)) +} + +/// SHA-256 of a token. Comparing hashes keeps the check independent of how +/// many leading characters of a guess are right. +#[derive(Clone, PartialEq, Eq)] +pub struct TokenHash([u8; 32]); + +impl TokenHash { + pub fn of(token: &str) -> Self { + Self(Sha256::digest(token.as_bytes()).into()) + } + + pub fn matches(&self, candidate: &str) -> bool { + let other = Self::of(candidate); + self.0 + .iter() + .zip(other.0.iter()) + .fold(0u8, |diff, (a, b)| diff | (a ^ b)) + == 0 + } + + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } +} + +impl fmt::Debug for TokenHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TokenHash()") + } +} + +/// Reads the owner token, creating it (and the secrets folder) on first run. +/// Both are restricted to the current user every time. +pub fn load_or_create_owner_token(secrets_dir: &Path) -> io::Result { + std::fs::create_dir_all(secrets_dir)?; + platform::restrict_to_current_user(secrets_dir)?; + let path = secrets_dir.join(OWNER_TOKEN_FILE); + let token = match std::fs::read_to_string(&path) { + Ok(text) if is_valid_token(text.trim()) => text.trim().to_owned(), + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{} is not a valid token; delete it to create a new one", + path.display() + ), + )); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + let token = random_token()?; + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, &token)?; + std::fs::rename(&tmp, &path)?; + token + } + Err(err) => return Err(err), + }; + platform::restrict_to_current_user(&path)?; + Ok(TokenHash::of(&token)) +} + +fn is_valid_token(text: &str) -> bool { + text.len() == TOKEN_BYTES * 2 && text.bytes().all(|b| b.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_tokens_are_hex_and_distinct() { + let a = random_token().expect("token"); + let b = random_token().expect("token"); + assert!(is_valid_token(&a)); + assert_ne!(a, b); + } + + #[test] + fn hash_matches_only_the_same_token() { + let hash = TokenHash::of("secret"); + assert!(hash.matches("secret")); + assert!(!hash.matches("secreT")); + assert!(!hash.matches("")); + assert_eq!(format!("{hash:?}"), "TokenHash()"); + } + + #[test] + fn owner_token_is_created_once_and_reused() { + let dir = tempfile::tempdir().expect("tempdir"); + let secrets = dir.path().join("secrets"); + let first = load_or_create_owner_token(&secrets).expect("create"); + let token = std::fs::read_to_string(secrets.join(OWNER_TOKEN_FILE)).expect("read"); + assert!(first.matches(&token)); + let second = load_or_create_owner_token(&secrets).expect("reuse"); + assert_eq!(first, second); + } + + #[test] + fn a_corrupt_token_file_is_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join(OWNER_TOKEN_FILE), "not-a-token").expect("write"); + assert!(load_or_create_owner_token(dir.path()).is_err()); + } +} diff --git a/crates/botloftd/src/server.rs b/crates/botloftd/src/server.rs new file mode 100644 index 00000000..83056ef2 --- /dev/null +++ b/crates/botloftd/src/server.rs @@ -0,0 +1,79 @@ +//! HTTP server on 127.0.0.1: `GET /health`, the `/rpc` WebSocket for the +//! app and `POST /hooks/{event}` for the bots. `/mcp` joins in M3. + +use std::future::Future; +use std::sync::Arc; + +use axum::Router; +use axum::extract::{DefaultBodyLimit, State, WebSocketUpgrade}; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use botloft_core::protocol::PROTOCOL_VERSION; +use serde_json::json; +use tokio::net::TcpListener; + +use crate::state::Daemon; +use crate::{hooks, rpc}; + +/// Browser origins allowed to open `/rpc` (spec 11.1): the Tauri app in +/// production and the Vite dev server. Clients that send no `Origin`, such +/// as native tools and tests, are allowed; they still need the owner token. +pub const ALLOWED_ORIGINS: &[&str] = &[ + "http://tauri.localhost", + "tauri://localhost", + "http://localhost:1420", +]; + +/// Largest WebSocket message accepted from the app. +const MAX_MESSAGE_BYTES: usize = 4 << 20; +/// Largest hook body; payloads are small JSON objects. +const MAX_HOOK_BYTES: usize = 1 << 20; + +pub fn router(daemon: Arc) -> Router { + Router::new() + .route("/health", get(health)) + .route("/rpc", get(rpc_upgrade)) + .route( + "/hooks/{event}", + post(hooks::handle).layer(DefaultBodyLimit::max(MAX_HOOK_BYTES)), + ) + .with_state(daemon) +} + +/// Serves until `shutdown` resolves. +pub async fn serve( + daemon: Arc, + listener: TcpListener, + shutdown: impl Future + Send + 'static, +) -> std::io::Result<()> { + axum::serve(listener, router(daemon)) + .with_graceful_shutdown(shutdown) + .await +} + +async fn health() -> impl IntoResponse { + axum::Json(json!({ + "status": "ok", + "version": env!("CARGO_PKG_VERSION"), + "protocol": PROTOCOL_VERSION, + })) +} + +async fn rpc_upgrade( + State(daemon): State>, + headers: HeaderMap, + upgrade: WebSocketUpgrade, +) -> Response { + if let Some(origin) = headers.get(header::ORIGIN) { + let allowed = origin + .to_str() + .is_ok_and(|origin| ALLOWED_ORIGINS.contains(&origin)); + if !allowed { + return (StatusCode::FORBIDDEN, "origin not allowed").into_response(); + } + } + upgrade + .max_message_size(MAX_MESSAGE_BYTES) + .on_upgrade(move |socket| rpc::serve_connection(socket, daemon)) +} diff --git a/crates/botloftd/src/service/bots.rs b/crates/botloftd/src/service/bots.rs new file mode 100644 index 00000000..280725c8 --- /dev/null +++ b/crates/botloftd/src/service/bots.rs @@ -0,0 +1,212 @@ +//! `bots.*` operations. + +use botloft_core::ids::{BotId, CrewId}; +use botloft_core::protocol::{ + Bot, BotIdParams, BotState, BotsCreateParams, BotsListParams, BotsRestartParams, + BotsSetPausedParams, BotsUpdateParams, Crew, +}; +use botloft_core::{avatar, now_ms, slug, validate}; +use botloft_store::{BotRecord, Store}; + +use super::{ApiError, ApiResult, crews, pick_slug}; +use crate::paths::SHARED_DIR; +use crate::state::{Daemon, Event}; +use crate::workspace; + +pub fn list(daemon: &Daemon, params: BotsListParams) -> ApiResult> { + let store = daemon.store(); + let records = match ¶ms.crew_id { + Some(crew_id) => { + crews::find(&store, crew_id)?; + store.bots(Some(crew_id), false)? + } + None => store.bots(None, false)?, + }; + records + .into_iter() + .map(|record| { + let crew = crews::find(&store, &record.crew_id)?; + Ok(to_protocol(daemon, &crew, record)) + }) + .collect() +} + +pub fn create(daemon: &Daemon, params: BotsCreateParams) -> ApiResult { + let name = validate::name("name", ¶ms.name)?; + let role = validate::role(¶ms.role)?; + let instructions = validate::instructions(¶ms.instructions)?; + let store = daemon.store(); + let crew = crews::active(&store, ¶ms.crew_id)?; + + let handle = slug::slugify(&name, "bot"); + ensure_handle_free(&store, &crew.id, &handle, None)?; + let color = match params.color { + Some(color) => parse_color(&color)?, + None => avatar::palette_color(store.count_bots(&crew.id)?).to_owned(), + }; + let slug = pick_slug(&handle, |candidate| { + Ok(candidate == SHARED_DIR + || store.bot_slug_exists(&crew.id, candidate)? + || daemon.paths.bot_workspace(&crew.slug, candidate).exists()) + })?; + + let record = BotRecord { + id: BotId::generate(), + crew_id: crew.id.clone(), + name, + handle, + slug, + role, + instructions, + color, + paused: false, + created_at: now_ms(), + archived_at: None, + }; + workspace::prepare_bot(daemon.workspace_env(), &crew, &record).map_err(ApiError::Workspace)?; + store.insert_bot(&record)?; + let bot = changed(daemon, &crew, record); + daemon.supervisor.wake(); + Ok(bot) +} + +pub fn update(daemon: &Daemon, params: BotsUpdateParams) -> ApiResult { + let store = daemon.store(); + let (crew, mut record) = active(&store, ¶ms.bot_id)?; + if let Some(name) = params.name { + record.name = validate::name("name", &name)?; + record.handle = slug::slugify(&record.name, "bot"); + ensure_handle_free(&store, &crew.id, &record.handle, Some(&record.id))?; + } + if let Some(role) = params.role { + record.role = validate::role(&role)?; + } + if let Some(instructions) = params.instructions { + record.instructions = validate::instructions(&instructions)?; + } + if let Some(color) = params.color { + record.color = parse_color(&color)?; + } + // Rules first: if the disk write fails, nothing is saved. A running bot + // reads them at its next start. + workspace::write_rules(&daemon.paths, &crew, &record).map_err(ApiError::Workspace)?; + store.update_bot(&record)?; + Ok(changed(daemon, &crew, record)) +} + +pub fn set_paused(daemon: &Daemon, params: BotsSetPausedParams) -> ApiResult { + let store = daemon.store(); + let (crew, mut record) = active(&store, ¶ms.bot_id)?; + if record.paused == params.paused { + return Ok(to_protocol(daemon, &crew, record)); + } + record.paused = params.paused; + store.update_bot(&record)?; + let bot = changed(daemon, &crew, record); + daemon.supervisor.wake(); + Ok(bot) +} + +/// Restarts the process; `fresh` drops the conversation. Also the way out +/// of `auth_error` (spec 7.3). +pub fn restart(daemon: &Daemon, params: BotsRestartParams) -> ApiResult { + let store = daemon.store(); + let (crew, record) = active(&store, ¶ms.bot_id)?; + if record.paused || crew.paused { + return Err(ApiError::Conflict(format!( + "bot {} is paused; resume it instead", + record.id + ))); + } + daemon + .supervisor + .restart(&record.id, params.fresh.unwrap_or(false)); + Ok(to_protocol(daemon, &crew, record)) +} + +/// Archives the bot and stops its process. Archiving twice is not an error. +/// The workspace stays on disk for now (spec 14). +pub fn archive(daemon: &Daemon, params: BotIdParams) -> ApiResult { + let store = daemon.store(); + let mut record = find(&store, ¶ms.bot_id)?; + let crew = crews::find(&store, &record.crew_id)?; + if record.archived_at.is_some() { + return Ok(to_protocol(daemon, &crew, record)); + } + record.archived_at = Some(now_ms()); + store.update_bot(&record)?; + let bot = changed(daemon, &crew, record); + daemon.supervisor.wake(); + Ok(bot) +} + +/// The protocol view of a stored bot, with the supervisor's live state. +pub(crate) fn to_protocol(daemon: &Daemon, crew: &Crew, record: BotRecord) -> Bot { + let (live, generation) = daemon + .supervisor + .status(&record.id) + .unwrap_or((BotState::Offline, None)); + let state = if record.archived_at.is_some() || crew.archived_at.is_some() { + BotState::Archived + } else { + live + }; + let workspace = daemon.paths.bot_workspace(&crew.slug, &record.slug); + Bot { + id: record.id, + crew_id: record.crew_id, + name: record.name, + handle: record.handle, + slug: record.slug, + role: record.role, + instructions: record.instructions, + color: record.color, + paused: record.paused, + state, + generation, + workspace: workspace.to_string_lossy().into_owned(), + created_at: record.created_at, + archived_at: record.archived_at, + } +} + +fn changed(daemon: &Daemon, crew: &Crew, record: BotRecord) -> Bot { + let bot = to_protocol(daemon, crew, record); + daemon.emit(Event::BotChanged(bot.clone())); + bot +} + +pub(crate) fn find(store: &Store, id: &BotId) -> ApiResult { + store + .bot(id)? + .ok_or_else(|| ApiError::NotFound(format!("bot {id} does not exist"))) +} + +/// The bot and its crew, if the bot exists and is not archived. +fn active(store: &Store, id: &BotId) -> ApiResult<(Crew, BotRecord)> { + let record = find(store, id)?; + if record.archived_at.is_some() { + return Err(ApiError::Conflict(format!("bot {id} is archived"))); + } + let crew = crews::active(store, &record.crew_id)?; + Ok((crew, record)) +} + +fn ensure_handle_free( + store: &Store, + crew: &CrewId, + handle: &str, + except: Option<&BotId>, +) -> ApiResult<()> { + match store.active_bot_by_handle(crew, handle)? { + Some(owner) if Some(&owner) != except => Err(ApiError::validation(format!( + "another bot in this crew already answers to @{handle}; pick a different name" + ))), + _ => Ok(()), + } +} + +fn parse_color(input: &str) -> ApiResult { + avatar::normalize_color(input) + .ok_or_else(|| ApiError::validation("color must be a hex color like #5EC8FF")) +} diff --git a/crates/botloftd/src/service/crews.rs b/crates/botloftd/src/service/crews.rs new file mode 100644 index 00000000..2bd6c2b3 --- /dev/null +++ b/crates/botloftd/src/service/crews.rs @@ -0,0 +1,104 @@ +//! `crews.*` operations. + +use botloft_core::ids::CrewId; +use botloft_core::protocol::{ + Crew, CrewIdParams, CrewsCreateParams, CrewsRenameParams, CrewsSetPausedParams, +}; +use botloft_core::{now_ms, slug, validate}; +use botloft_store::Store; +use tracing::warn; + +use super::{ApiError, ApiResult, bots, pick_slug}; +use crate::state::{Daemon, Event}; +use crate::workspace; + +pub fn list(daemon: &Daemon) -> ApiResult> { + Ok(daemon.store().crews(false)?) +} + +pub fn create(daemon: &Daemon, params: CrewsCreateParams) -> ApiResult { + let name = validate::name("name", ¶ms.name)?; + let store = daemon.store(); + // A folder left on disk by something else is never adopted. + let slug = pick_slug(&slug::slugify(&name, "crew"), |candidate| { + Ok(store.crew_slug_exists(candidate)? || daemon.paths.crew_dir(candidate).exists()) + })?; + workspace::prepare_crew(&daemon.paths, &slug).map_err(ApiError::Workspace)?; + + let crew = Crew { + id: CrewId::generate(), + name, + slug, + paused: false, + created_at: now_ms(), + archived_at: None, + }; + store.insert_crew(&crew)?; + daemon.emit(Event::CrewChanged(crew.clone())); + Ok(crew) +} + +pub fn rename(daemon: &Daemon, params: CrewsRenameParams) -> ApiResult { + let name = validate::name("name", ¶ms.name)?; + let store = daemon.store(); + let mut crew = active(&store, ¶ms.crew_id)?; + crew.name = name; + store.update_crew(&crew)?; + + // The crew name appears in every bot's rules file. + for bot in store.bots(Some(&crew.id), false)? { + if let Err(err) = workspace::write_rules(&daemon.paths, &crew, &bot) { + warn!(bot = %bot.id, "could not refresh the bot rules after a crew rename: {err}"); + } + } + daemon.emit(Event::CrewChanged(crew.clone())); + Ok(crew) +} + +pub fn set_paused(daemon: &Daemon, params: CrewsSetPausedParams) -> ApiResult { + let store = daemon.store(); + let mut crew = active(&store, ¶ms.crew_id)?; + if crew.paused != params.paused { + crew.paused = params.paused; + store.update_crew(&crew)?; + daemon.emit(Event::CrewChanged(crew.clone())); + daemon.supervisor.wake(); + } + Ok(crew) +} + +/// Archives the crew and its bots. Archiving twice is not an error. +pub fn archive(daemon: &Daemon, params: CrewIdParams) -> ApiResult { + let store = daemon.store(); + let crew = find(&store, ¶ms.crew_id)?; + if crew.archived_at.is_some() { + return Ok(crew); + } + let bots = store.bots(Some(&crew.id), false)?; + store.archive_crew(&crew.id, now_ms())?; + + let crew = find(&store, &crew.id)?; + for bot in bots { + if let Some(bot) = store.bot(&bot.id)? { + daemon.emit(Event::BotChanged(bots::to_protocol(daemon, &crew, bot))); + } + } + daemon.emit(Event::CrewChanged(crew.clone())); + daemon.supervisor.wake(); + Ok(crew) +} + +pub(crate) fn find(store: &Store, id: &CrewId) -> ApiResult { + store + .crew(id)? + .ok_or_else(|| ApiError::NotFound(format!("crew {id} does not exist"))) +} + +/// The crew, if it exists and is not archived. +pub(crate) fn active(store: &Store, id: &CrewId) -> ApiResult { + let crew = find(store, id)?; + if crew.archived_at.is_some() { + return Err(ApiError::Conflict(format!("crew {id} is archived"))); + } + Ok(crew) +} diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs new file mode 100644 index 00000000..599bd199 --- /dev/null +++ b/crates/botloftd/src/service/mod.rs @@ -0,0 +1,81 @@ +//! What the RPC methods do, independent of JSON-RPC. Each operation runs +//! under the store lock, so checks and writes cannot interleave. + +pub mod bots; +pub mod crews; +pub mod terminal; + +use botloft_core::protocol::{PROTOCOL_VERSION, SystemStatus, error_code}; +use botloft_core::slug; +use botloft_core::validate::ValidationError; +use botloft_store::StoreError; + +use crate::state::Daemon; + +/// A failed operation, mapped to the error codes of spec 11.4. +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("{0}")] + NotFound(String), + #[error("{0}")] + Conflict(String), + #[error(transparent)] + Validation(#[from] ValidationError), + #[error("could not update the workspace: {0}")] + Workspace(#[source] std::io::Error), + #[error("internal error")] + Internal(#[source] StoreError), +} + +impl ApiError { + pub fn code(&self) -> i32 { + match self { + Self::NotFound(_) => error_code::NOT_FOUND, + Self::Conflict(_) => error_code::CONFLICT, + Self::Validation(_) => error_code::VALIDATION, + Self::Workspace(_) | Self::Internal(_) => error_code::INTERNAL_ERROR, + } + } + + pub(crate) fn validation(message: impl Into) -> Self { + Self::Validation(ValidationError(message.into())) + } +} + +impl From for ApiError { + fn from(err: StoreError) -> Self { + match err { + // Checks run before every insert, so this only happens on a race. + StoreError::Duplicate(what) => Self::Conflict(format!("{what} is already in use")), + other => Self::Internal(other), + } + } +} + +pub type ApiResult = Result; + +pub fn status(daemon: &Daemon) -> SystemStatus { + SystemStatus { + daemon_version: env!("CARGO_PKG_VERSION").to_owned(), + protocol: PROTOCOL_VERSION, + uptime_ms: daemon.uptime_ms(), + claude_version: daemon.supervisor.claude_version(), + runtime_error: daemon.supervisor.runtime_error(), + } +} + +/// [`slug::unique`] for checks that can fail. +fn pick_slug(base: &str, mut taken: impl FnMut(&str) -> ApiResult) -> ApiResult { + let mut failure = None; + let slug = slug::unique(base, |candidate| match taken(candidate) { + Ok(taken) => taken, + Err(err) => { + failure.get_or_insert(err); + false + } + }); + match failure { + Some(err) => Err(err), + None => Ok(slug), + } +} diff --git a/crates/botloftd/src/service/terminal.rs b/crates/botloftd/src/service/terminal.rs new file mode 100644 index 00000000..4b764646 --- /dev/null +++ b/crates/botloftd/src/service/terminal.rs @@ -0,0 +1,58 @@ +//! `terminal.*` operations (spec 8). Attaching needs the connection, so the +//! RPC layer streams the output; this module validates and prepares it. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use botloft_core::ids::BotId; +use botloft_core::protocol::{TerminalAttachParams, TerminalResizeParams, TerminalWriteParams}; +use bytes::Bytes; + +use super::{ApiError, ApiResult, bots}; +use crate::runtime::TermSize; +use crate::state::Daemon; +use crate::terminal::Attachment; + +/// Largest terminal the daemon accepts. +const MAX_CELLS: u16 = 1000; + +pub fn attach(daemon: &Daemon, params: TerminalAttachParams) -> ApiResult { + ensure_exists(daemon, ¶ms.bot_id)?; + let terminal = daemon.supervisor.terminal(¶ms.bot_id); + Ok(terminal.attach(params.generation, params.offset)) +} + +pub fn write(daemon: &Daemon, params: TerminalWriteParams) -> ApiResult<()> { + ensure_exists(daemon, ¶ms.bot_id)?; + let data = BASE64 + .decode(params.data.as_bytes()) + .map_err(|_| ApiError::validation("data must be base64"))?; + daemon + .supervisor + .write(¶ms.bot_id, Bytes::from(data)) + .map_err(|err| ApiError::Conflict(err.to_string())) +} + +pub fn resize(daemon: &Daemon, params: TerminalResizeParams) -> ApiResult<()> { + ensure_exists(daemon, ¶ms.bot_id)?; + let valid = |n: u16| (1..=MAX_CELLS).contains(&n); + if !valid(params.cols) || !valid(params.rows) { + return Err(ApiError::validation(format!( + "cols and rows must be between 1 and {MAX_CELLS}" + ))); + } + let size = TermSize { + cols: params.cols, + rows: params.rows, + }; + daemon.supervisor.resize(¶ms.bot_id, size); + Ok(()) +} + +/// Base64 for `terminal.data`. +pub fn encode(data: &[u8]) -> String { + BASE64.encode(data) +} + +fn ensure_exists(daemon: &Daemon, bot: &BotId) -> ApiResult<()> { + bots::find(&daemon.store(), bot).map(|_| ()) +} diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs new file mode 100644 index 00000000..0bc5064a --- /dev/null +++ b/crates/botloftd/src/state.rs @@ -0,0 +1,108 @@ +//! State shared by every connection: the store, the supervisor, the event +//! bus and what the daemon knows about itself. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Instant; + +use botloft_core::protocol::{Bot, BotStateChanged, Crew}; +use botloft_store::Store; +use tokio::sync::broadcast; + +use crate::paths::Paths; +use crate::runtime::Runtime; +use crate::secrets::TokenHash; +use crate::supervisor::{Supervisor, SupervisorSettings}; +use crate::workspace::WorkspaceEnv; + +/// Something that changed and every connected app should hear about. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + CrewChanged(Crew), + BotChanged(Bot), + BotState(BotStateChanged), +} + +/// Events buffered per connection before a slow client is dropped. +const EVENT_BUFFER: usize = 1024; + +pub struct DaemonOptions { + pub paths: Paths, + /// Port the daemon listens on, written into each bot's `mcp.json`. + pub port: u16, + /// Absolute path of `botloftd.exe`, run by the bots' hooks. + pub bin: PathBuf, + pub store: Store, + pub owner_token: TokenHash, + pub runtime: Arc, + pub supervisor: SupervisorSettings, +} + +pub struct Daemon { + pub paths: Paths, + pub port: u16, + pub bin: PathBuf, + pub supervisor: Supervisor, + store: Mutex, + owner_token: TokenHash, + events: broadcast::Sender, + started: Instant, +} + +impl Daemon { + /// The supervisor keeps a weak reference back to the daemon, so both are + /// created together. Start it with [`crate::supervisor::run`]. + pub fn new(options: DaemonOptions) -> Arc { + let (events, _) = broadcast::channel(EVENT_BUFFER); + Arc::new_cyclic(|daemon| Self { + supervisor: Supervisor::new( + daemon.clone(), + options.runtime, + options.supervisor, + events.clone(), + ), + paths: options.paths, + port: options.port, + bin: options.bin, + store: Mutex::new(options.store), + owner_token: options.owner_token, + events, + started: Instant::now(), + }) + } + + /// Locks the store. Hold the guard only for synchronous work, never + /// across an `.await`. + pub fn store(&self) -> MutexGuard<'_, Store> { + // A panic while holding the lock cannot leave SQLite half-written + // (every write is its own statement or transaction), so keep going. + self.store + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub fn workspace_env(&self) -> WorkspaceEnv<'_> { + WorkspaceEnv { + paths: &self.paths, + bin: &self.bin, + port: self.port, + } + } + + pub fn is_owner_token(&self, token: &str) -> bool { + self.owner_token.matches(token) + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } + + pub(crate) fn emit(&self, event: Event) { + // No receivers just means no app is connected. + let _ = self.events.send(event); + } + + pub fn uptime_ms(&self) -> i64 { + i64::try_from(self.started.elapsed().as_millis()).unwrap_or(i64::MAX) + } +} diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs new file mode 100644 index 00000000..4cbc2873 --- /dev/null +++ b/crates/botloftd/src/supervisor/mod.rs @@ -0,0 +1,285 @@ +//! Keeps every active bot running (spec 7): starts processes, follows their +//! state through hooks, restarts them with backoff and stops them when they +//! are paused or archived. +//! +//! Lock order: the store lock may be held while taking the supervisor lock +//! (service calls read states), never the other way around. + +mod reconcile; +mod settings; +mod slot; +mod spawn; + +use std::collections::HashMap; +use std::sync::atomic::AtomicU64; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; +use std::time::Duration; + +use botloft_core::ids::BotId; +use botloft_core::now_ms; +use botloft_core::protocol::{BotState, BotStateChanged}; +use bytes::Bytes; +use tokio::sync::{Notify, broadcast}; +use tokio::time::Instant; +use tracing::{debug, warn}; + +use self::reconcile::slot_entry; +pub use self::settings::{ClaudeSource, SupervisorSettings}; +pub use self::slot::{Hook, Inbox}; +use self::slot::{Slot, StopIntent, state_after_hook}; +use crate::runtime::claude::Claude; +use crate::runtime::{Runtime, TermSize}; +use crate::secrets::TokenHash; +use crate::state::{Daemon, Event}; +use crate::terminal::Terminal; + +/// How often the supervisor compares running processes with the database. +const RECONCILE_EVERY: Duration = Duration::from_secs(5); +/// A session this long resets the restart backoff (spec 7.3). +const STABLE_AFTER: Duration = Duration::from_secs(10 * 60); +/// How long a failed Claude Code probe is trusted before trying again. +const REPROBE_AFTER: Duration = Duration::from_secs(30); + +pub struct Supervisor { + daemon: Weak, + runtime: Arc, + settings: SupervisorSettings, + events: broadcast::Sender, + inner: Mutex, + /// Generations are unique across bots and daemon restarts, so a client + /// never mistakes a new process for one it saw before. + next_generation: AtomicU64, + reconcile_lock: tokio::sync::Mutex<()>, + wake: Notify, +} + +#[derive(Default)] +struct Inner { + slots: HashMap, + /// Token hash of each running generation -> its bot. + tokens: HashMap, + claude: ClaudeStatus, +} + +#[derive(Default)] +enum ClaudeStatus { + #[default] + Unknown, + Ready(Claude), + Failed { + error: String, + at: Instant, + }, +} + +impl Supervisor { + pub(crate) fn new( + daemon: Weak, + runtime: Arc, + settings: SupervisorSettings, + events: broadcast::Sender, + ) -> Self { + Self { + daemon, + runtime, + settings, + events, + inner: Mutex::default(), + next_generation: AtomicU64::new(u64::try_from(now_ms()).unwrap_or(1)), + reconcile_lock: tokio::sync::Mutex::new(()), + wake: Notify::new(), + } + } + + fn lock(&self) -> MutexGuard<'_, Inner> { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Asks the run loop to reconcile now instead of at the next tick. + pub fn wake(&self) { + self.wake.notify_one(); + } + + /// State and generation of a bot the supervisor has seen. + pub fn status(&self, bot: &BotId) -> Option<(BotState, Option)> { + let inner = self.lock(); + inner + .slots + .get(bot) + .map(|slot| (slot.state, slot.generation)) + } + + pub fn claude_version(&self) -> Option { + match &self.lock().claude { + ClaudeStatus::Ready(claude) => Some(claude.version.clone()), + _ => None, + } + } + + /// Why bots cannot start, if Claude Code is missing or too old. + pub fn runtime_error(&self) -> Option { + match &self.lock().claude { + ClaudeStatus::Failed { error, .. } => Some(error.clone()), + _ => None, + } + } + + pub fn terminal(&self, bot: &BotId) -> Arc { + let mut inner = self.lock(); + Arc::clone(&self.slot(&mut inner, bot).terminal) + } + + fn slot<'a>(&self, inner: &'a mut Inner, bot: &BotId) -> &'a mut Slot { + slot_entry(&mut inner.slots, bot, &self.settings) + } + + /// Sends keystrokes to the bot. Answering a permission prompt resumes + /// the turn (spec 7.2). + pub fn write(&self, bot: &BotId, data: Bytes) -> Result<(), NotRunning> { + let mut inner = self.lock(); + let slot = self.slot(&mut inner, bot); + let running = slot.running.as_ref().ok_or(NotRunning)?; + running.control.write(data).map_err(|_| NotRunning)?; + if slot.state == BotState::NeedsApproval { + self.set_state(bot, slot, BotState::Busy); + } + Ok(()) + } + + /// Answers the process of `generation` on behalf of the terminal. + pub(crate) fn reply(&self, bot: &BotId, generation: u64, data: &'static [u8]) { + let inner = self.lock(); + let running = inner + .slots + .get(bot) + .filter(|slot| slot.generation == Some(generation)) + .and_then(|slot| slot.running.as_ref()); + if let Some(running) = running { + let _ = running.control.write(Bytes::from_static(data)); + } + } + + /// Remembers the size for the next start and applies it now if running. + pub fn resize(&self, bot: &BotId, size: TermSize) { + let mut inner = self.lock(); + let slot = self.slot(&mut inner, bot); + slot.size = size; + if let Some(running) = &slot.running + && let Err(err) = running.control.resize(size) + { + debug!(bot = %bot, "resize failed: {err}"); + } + } + + /// Restarts the bot, also out of `auth_error`. The caller checked that + /// the bot should run. + pub fn restart(&self, bot: &BotId, fresh: bool) { + let mut inner = self.lock(); + let slot = self.slot(&mut inner, bot); + slot.backoff.reset(); + match &slot.running { + Some(running) => { + slot.stop = Some(StopIntent::Restart { fresh }); + if let Err(err) = running.control.kill() { + warn!(bot = %bot, "could not stop the bot for a restart: {err}"); + } + } + None => { + slot.fresh_next = fresh; + slot.restart_at = None; + if slot.state == BotState::AuthError { + self.set_state(bot, slot, BotState::Offline); + } + } + } + drop(inner); + self.wake(); + } + + /// The bot and generation a hook token belongs to. + pub fn hook_owner(&self, token: &str) -> Option<(BotId, u64)> { + self.lock() + .tokens + .get(&TokenHash::of(token).to_hex()) + .cloned() + } + + pub fn on_hook(&self, bot: &BotId, generation: u64, hook: Hook) { + let mut inner = self.lock(); + let slot = self.slot(&mut inner, bot); + if slot.generation != Some(generation) || slot.running.is_none() { + return; + } + debug!(bot = %bot, generation, ?hook, "hook"); + if let Hook::SessionStart { inbox } = &hook { + slot.inbox.clone_from(inbox); + if let Some(running) = &slot.running { + spawn::mark_started(&running.workspace); + } + } + if let Some(next) = state_after_hook(slot.state, &hook) { + self.set_state(bot, slot, next); + } + } + + /// Where to deliver messages to the bot's current generation. + pub fn inbox(&self, bot: &BotId) -> Option { + self.lock() + .slots + .get(bot) + .and_then(|slot| slot.inbox.clone()) + } + + fn set_state(&self, bot: &BotId, slot: &mut Slot, state: BotState) { + if slot.state != state { + slot.state = state; + self.announce(bot, slot); + } + } + + /// Emits `bot.state`. A new process always announces itself, even when + /// the state name did not change, because its generation did. + fn announce(&self, bot: &BotId, slot: &Slot) { + let _ = self.events.send(Event::BotState(BotStateChanged { + bot_id: bot.clone(), + state: slot.state, + generation: slot.generation, + })); + } + + /// Kills every bot, for daemon shutdown. + pub fn shutdown(&self) { + let inner = self.lock(); + for running in inner + .slots + .values() + .filter_map(|slot| slot.running.as_ref()) + { + let _ = running.control.kill(); + } + } +} + +/// The bot has no running process. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("the bot is not running")] +pub struct NotRunning; + +/// Reconciles on start, every few seconds and whenever something wakes the +/// loop. Runs until the daemon is dropped. +pub async fn run(daemon: Arc) { + let supervisor = &daemon.supervisor; + loop { + supervisor.reconcile().await; + let next = supervisor.next_restart().map_or(RECONCILE_EVERY, |at| { + at.saturating_duration_since(Instant::now()) + .min(RECONCILE_EVERY) + }); + tokio::select! { + () = tokio::time::sleep(next) => {} + () = supervisor.wake.notified() => {} + } + } +} diff --git a/crates/botloftd/src/supervisor/reconcile.rs b/crates/botloftd/src/supervisor/reconcile.rs new file mode 100644 index 00000000..73afb059 --- /dev/null +++ b/crates/botloftd/src/supervisor/reconcile.rs @@ -0,0 +1,169 @@ +//! Comparing what runs with what the database says should run. + +use std::collections::HashMap; +use std::sync::Arc; + +use botloft_core::ids::BotId; +use botloft_core::protocol::{BotState, Crew}; +use botloft_store::BotRecord; +use tokio::time::Instant; +use tracing::warn; + +use super::slot::{Backoff, Slot, StopIntent}; +use super::{ClaudeSource, ClaudeStatus, Inner, REPROBE_AFTER, Supervisor, SupervisorSettings}; +use crate::platform; +use crate::runtime::claude::{self, Claude, ClaudeError}; +use crate::state::Daemon; + +impl Supervisor { + /// Starts bots that should run and stops those that should not. + pub async fn reconcile(&self) { + let _one_at_a_time = self.reconcile_lock.lock().await; + let Some(daemon) = self.daemon.upgrade() else { + return; + }; + self.ensure_claude().await; + let read = { + let store = daemon.store(); + store + .bots(None, true) + .and_then(|bots| Ok((bots, store.crews(true)?))) + }; + let (bots, crews) = match read { + Ok(read) => read, + Err(err) => { + warn!("reconcile could not read the database: {err}"); + return; + } + }; + let crews: HashMap<_, _> = crews + .into_iter() + .map(|crew| (crew.id.clone(), crew)) + .collect(); + let now = Instant::now(); + let mut inner = self.lock(); + for bot in &bots { + if let Some(crew) = crews.get(&bot.crew_id) { + self.reconcile_bot(&mut inner, &daemon, crew, bot, now); + } + } + } + + fn reconcile_bot( + &self, + inner: &mut Inner, + daemon: &Arc, + crew: &Crew, + bot: &BotRecord, + now: Instant, + ) { + let archived = bot.archived_at.is_some() || crew.archived_at.is_some(); + if archived && !inner.slots.contains_key(&bot.id) { + return; + } + let wanted = !archived && !bot.paused && !crew.paused; + let slot = slot_entry(&mut inner.slots, &bot.id, &self.settings); + if !wanted { + let target = if archived { + BotState::Archived + } else { + BotState::Offline + }; + slot.restart_at = None; + match &slot.running { + Some(running) if slot.stop.is_none() => { + slot.stop = Some(StopIntent::Halt(target)); + if let Err(err) = running.control.kill() { + warn!(bot = %bot.id, "could not stop the bot: {err}"); + } + } + Some(_) => {} + None => self.set_state(&bot.id, slot, target), + } + return; + } + // A process on its way out settles first; its exit wakes the loop. + let waiting = slot.restart_at.is_some_and(|at| at > now); + if slot.running.is_some() || slot.stop.is_some() || waiting { + return; + } + if slot.state == BotState::AuthError { + return; + } + self.start(inner, daemon, crew, bot); + } + + /// Earliest scheduled restart, so the run loop can wake for it. + pub(super) fn next_restart(&self) -> Option { + let inner = self.lock(); + inner + .slots + .values() + .filter(|slot| slot.running.is_none()) + .filter_map(|slot| slot.restart_at) + .min() + } + + /// Finds and checks Claude Code when it is unknown or failed a while ago. + async fn ensure_claude(&self) { + let (due, previous) = match &self.lock().claude { + ClaudeStatus::Unknown => (true, None), + ClaudeStatus::Ready(_) => (false, None), + ClaudeStatus::Failed { error, at } => { + (at.elapsed() >= REPROBE_AFTER, Some(error.clone())) + } + }; + if !due { + return; + } + let source = self.settings.claude.clone(); + let probed = match source { + ClaudeSource::Fixed(claude) => Ok(Ok(claude)), + // Runs `claude --version`; keep it off the async workers. + source @ ClaudeSource::Discover { .. } => { + tokio::task::spawn_blocking(move || probe(&source)).await + } + }; + let status = match probed { + Ok(Ok(claude)) => ClaudeStatus::Ready(claude), + Ok(Err(err)) => failed(err.to_string(), previous.as_deref()), + Err(err) => failed( + format!("the Claude Code check crashed: {err}"), + previous.as_deref(), + ), + }; + self.lock().claude = status; + } +} + +fn failed(error: String, previous: Option<&str>) -> ClaudeStatus { + if previous != Some(error.as_str()) { + warn!("bots cannot start: {error}"); + } + ClaudeStatus::Failed { + error, + at: Instant::now(), + } +} + +fn probe(source: &ClaudeSource) -> Result { + match source { + ClaudeSource::Fixed(claude) => Ok(claude.clone()), + ClaudeSource::Discover { configured } => { + let env = platform::user_environment().map_err(ClaudeError::Environment)?; + let path = claude::locate(configured, &env)?; + claude::probe(&path, &env) + } + } +} + +pub(super) fn slot_entry<'a>( + slots: &'a mut HashMap, + bot: &BotId, + settings: &SupervisorSettings, +) -> &'a mut Slot { + slots.entry(bot.clone()).or_insert_with(|| { + let backoff = Backoff::new(settings.backoff_initial, settings.backoff_max); + Slot::new(settings.ring_buffer_bytes, backoff) + }) +} diff --git a/crates/botloftd/src/supervisor/settings.rs b/crates/botloftd/src/supervisor/settings.rs new file mode 100644 index 00000000..3a0e3328 --- /dev/null +++ b/crates/botloftd/src/supervisor/settings.rs @@ -0,0 +1,39 @@ +//! Supervisor configuration, from `config.toml` or set by tests. + +use std::time::Duration; + +use crate::config::Config; +use crate::runtime::claude::Claude; + +#[derive(Debug, Clone)] +pub enum ClaudeSource { + /// `claude_path` from the config, or `PATH`; checked with `--version`. + Discover { configured: String }, + /// A known executable, used without running it (tests). + Fixed(Claude), +} + +#[derive(Debug, Clone)] +pub struct SupervisorSettings { + pub claude: ClaudeSource, + pub backoff_initial: Duration, + pub backoff_max: Duration, + pub fresh_start_if_dies_within: Duration, + pub ring_buffer_bytes: usize, +} + +impl SupervisorSettings { + pub fn from_config(config: &Config) -> Self { + Self { + claude: ClaudeSource::Discover { + configured: config.claude_path.clone(), + }, + backoff_initial: Duration::from_millis(config.supervisor.restart_backoff_initial_ms), + backoff_max: Duration::from_millis(config.supervisor.restart_backoff_max_ms), + fresh_start_if_dies_within: Duration::from_secs( + config.supervisor.fresh_start_if_dies_within_s, + ), + ring_buffer_bytes: config.terminal.ring_buffer_bytes, + } + } +} diff --git a/crates/botloftd/src/supervisor/slot.rs b/crates/botloftd/src/supervisor/slot.rs new file mode 100644 index 00000000..bda6ef09 --- /dev/null +++ b/crates/botloftd/src/supervisor/slot.rs @@ -0,0 +1,231 @@ +//! Per-bot supervision state and the transition rules of spec 7.2. + +use std::fmt; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use botloft_core::protocol::BotState; +use tokio::time::Instant; + +use crate::runtime::{ProcessControl, TermSize}; +use crate::terminal::Terminal; + +pub(super) struct Slot { + pub state: BotState, + /// Generation of the current or last process. + pub generation: Option, + pub running: Option, + pub backoff: Backoff, + pub restart_at: Option, + /// Start the next process without `--continue`. + pub fresh_next: bool, + /// Why the running process is being killed, if the daemon asked. + pub stop: Option, + pub size: TermSize, + pub terminal: Arc, + /// Where to deliver messages to this generation (spec 9.2); memory only. + pub inbox: Option, +} + +impl Slot { + pub fn new(ring_buffer_bytes: usize, backoff: Backoff) -> Self { + Self { + state: BotState::Offline, + generation: None, + running: None, + backoff, + restart_at: None, + fresh_next: false, + stop: None, + size: TermSize::default(), + terminal: Arc::new(Terminal::new(ring_buffer_bytes)), + inbox: None, + } + } +} + +pub(super) struct Running { + pub control: Box, + pub started: Instant, + /// Started with `--continue`. + pub resumed: bool, + pub token_hash: String, + pub workspace: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum StopIntent { + Restart { + fresh: bool, + }, + /// Paused or archived: settle in this state. + Halt(BotState), +} + +/// The bot's inbox, reported by the `SessionStart` hook. +#[derive(Clone, PartialEq, Eq)] +pub struct Inbox { + pub socket: String, + pub token: String, +} + +impl fmt::Debug for Inbox { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Inbox") + .field("socket", &self.socket) + .field("token", &"") + .finish() + } +} + +/// What a hook reported. Payload fields as documented by Claude Code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Hook { + SessionStart { + inbox: Option, + }, + PromptSubmit, + Stop, + /// `error`: `rate_limit`, `authentication_failed`, ... + StopFailure { + error: String, + }, + /// `notification_type`: `permission_prompt`, `idle_prompt`, ... + Notification { + kind: String, + }, + SessionEnd, +} + +/// The state a hook moves the bot to, if it changes it (spec 7.2). +pub(super) fn state_after_hook(current: BotState, hook: &Hook) -> Option { + let next = match hook { + Hook::SessionStart { .. } | Hook::Stop => BotState::Idle, + Hook::PromptSubmit => BotState::Busy, + Hook::StopFailure { error } => match error.as_str() { + "rate_limit" => BotState::RateLimited, + "authentication_failed" | "oauth_org_not_allowed" => BotState::AuthError, + // The turn ended; the session is still usable. + _ => BotState::Idle, + }, + Hook::Notification { kind } if kind == "permission_prompt" => BotState::NeedsApproval, + Hook::Notification { .. } | Hook::SessionEnd => return None, + }; + (next != current).then_some(next) +} + +/// Exponential restart delay with jitter (spec 7.3). +#[derive(Debug, Clone)] +pub(super) struct Backoff { + initial: Duration, + max: Duration, + attempts: u32, +} + +impl Backoff { + pub fn new(initial: Duration, max: Duration) -> Self { + Self { + initial, + max: max.max(initial), + attempts: 0, + } + } + + pub fn reset(&mut self) { + self.attempts = 0; + } + + /// Between half and all of `initial * 2^attempts`, capped at `max`. + pub fn next_delay(&mut self) -> Duration { + let factor = 1u32.checked_shl(self.attempts.min(20)).unwrap_or(u32::MAX); + let base = self.initial.saturating_mul(factor).min(self.max); + self.attempts = self.attempts.saturating_add(1); + let half = base / 2; + let jitter = random_below(half.as_millis() as u64 + 1); + half + Duration::from_millis(jitter) + } +} + +fn random_below(bound: u64) -> u64 { + let mut bytes = [0u8; 8]; + if getrandom::fill(&mut bytes).is_err() || bound == 0 { + return 0; + } + u64::from_le_bytes(bytes) % bound +} + +#[cfg(test)] +mod tests { + use super::*; + + fn after(current: BotState, hook: Hook) -> Option { + state_after_hook(current, &hook) + } + + #[test] + fn hooks_move_the_bot_through_its_states() { + use BotState::*; + assert_eq!( + after(Launching, Hook::SessionStart { inbox: None }), + Some(Idle) + ); + assert_eq!(after(Idle, Hook::PromptSubmit), Some(Busy)); + assert_eq!(after(Busy, Hook::Stop), Some(Idle)); + let permission = Hook::Notification { + kind: "permission_prompt".into(), + }; + assert_eq!(after(Busy, permission), Some(NeedsApproval)); + let idle_prompt = Hook::Notification { + kind: "idle_prompt".into(), + }; + assert_eq!(after(Idle, idle_prompt), None); + assert_eq!(after(Idle, Hook::SessionEnd), None); + assert_eq!(after(Idle, Hook::Stop), None, "no change, no event"); + } + + #[test] + fn stop_failures_map_to_their_states() { + use BotState::*; + let failure = |error: &str| Hook::StopFailure { + error: error.into(), + }; + assert_eq!(after(Busy, failure("rate_limit")), Some(RateLimited)); + assert_eq!( + after(Busy, failure("authentication_failed")), + Some(AuthError) + ); + assert_eq!(after(Busy, failure("server_error")), Some(Idle)); + } + + #[test] + fn backoff_grows_with_jitter_up_to_the_cap_and_resets() { + let mut backoff = Backoff::new(Duration::from_secs(1), Duration::from_secs(10)); + let bounds = [ + (500, 1000), + (1000, 2000), + (2000, 4000), + (4000, 8000), + (5000, 10000), + ]; + for (low, high) in bounds { + let delay = backoff.next_delay().as_millis(); + assert!( + (low..=high).contains(&delay), + "{delay} not in {low}..={high}" + ); + } + assert!(backoff.next_delay() <= Duration::from_secs(10)); + backoff.reset(); + assert!(backoff.next_delay() <= Duration::from_secs(1)); + } + + #[test] + fn inbox_debug_hides_the_token() { + let inbox = Inbox { + socket: r"\\.\pipe\x".into(), + token: "secret".into(), + }; + assert!(!format!("{inbox:?}").contains("secret")); + } +} diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs new file mode 100644 index 00000000..dcd8f296 --- /dev/null +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -0,0 +1,302 @@ +//! Starting a bot process (spec 7.4) and following it until it exits. + +use std::ffi::OsString; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Weak}; +use std::time::Duration; + +use botloft_core::ids::BotId; +use botloft_core::protocol::{BotState, Crew}; +use botloft_store::BotRecord; +use bytes::BytesMut; +use tokio::sync::mpsc; +use tokio::time::Instant; +use tracing::{debug, warn}; + +use super::slot::{Running, StopIntent}; +use super::{ClaudeStatus, Inner, STABLE_AFTER, Supervisor}; +use crate::platform; +use crate::runtime::claude::Claude; +use crate::runtime::{ProcessEvent, SpawnSpec}; +use crate::secrets::{self, TokenHash}; +use crate::state::Daemon; +use crate::terminal::{CursorQueryWatch, Terminal}; +use crate::workspace; + +/// Output read within this window goes out as one chunk (spec 8). +const COALESCE_WINDOW: Duration = Duration::from_millis(8); +const COALESCE_MAX: usize = 32 * 1024; +/// Written after the first `SessionStart`; without it there is no +/// conversation to `--continue`. +const STARTED_MARKER: &str = ".botloft/started"; + +impl Supervisor { + pub(super) fn start( + &self, + inner: &mut Inner, + daemon: &Arc, + crew: &Crew, + bot: &BotRecord, + ) { + let Inner { + slots, + tokens, + claude, + } = inner; + let Some(slot) = slots.get_mut(&bot.id) else { + return; + }; + let ClaudeStatus::Ready(claude) = claude else { + self.set_state(&bot.id, slot, BotState::Offline); + return; + }; + let generation = self + .next_generation + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let launched = launch_spec(daemon, crew, bot, claude, slot.fresh_next, slot.size) + .and_then(|(spec, launch)| Ok((self.runtime.spawn(spec)?, launch))); + match launched { + Ok((process, launch)) => { + slot.generation = Some(generation); + slot.terminal.reset(generation); + slot.restart_at = None; + slot.fresh_next = false; + slot.inbox = None; + tokens.insert(launch.token_hash.clone(), (bot.id.clone(), generation)); + slot.running = Some(Running { + control: process.control, + started: Instant::now(), + resumed: launch.resumed, + token_hash: launch.token_hash, + workspace: launch.workspace, + }); + slot.state = BotState::Launching; + self.announce(&bot.id, slot); + debug!(bot = %bot.id, generation, pid = ?process.pid, resumed = launch.resumed, "bot started"); + tokio::spawn(pump( + self.daemon.clone(), + bot.id.clone(), + generation, + Arc::clone(&slot.terminal), + process.events, + )); + } + Err(err) => { + warn!(bot = %bot.id, "could not start the bot: {err}"); + slot.restart_at = Some(Instant::now() + slot.backoff.next_delay()); + self.set_state(&bot.id, slot, BotState::Backoff); + } + } + } + + /// The process of `generation` ended: restart it, or settle if the + /// daemon stopped it on purpose (spec 7.3). + pub(super) fn on_exit(&self, bot: &BotId, generation: u64, code: Option) { + let mut inner = self.lock(); + let Inner { slots, tokens, .. } = &mut *inner; + let Some(slot) = slots.get_mut(bot) else { + return; + }; + if slot.generation != Some(generation) { + return; + } + let Some(running) = slot.running.take() else { + return; + }; + tokens.remove(&running.token_hash); + slot.inbox = None; + let ran = running.started.elapsed(); + let resumed = running.resumed; + // Closing the job kills anything the bot left behind. + drop(running); + debug!(bot = %bot, generation, ?code, ran_ms = ran.as_millis() as u64, "bot exited"); + + match slot.stop.take() { + Some(StopIntent::Restart { fresh }) => { + slot.fresh_next = fresh; + slot.restart_at = Some(Instant::now()); + } + Some(StopIntent::Halt(state)) => { + slot.restart_at = None; + self.set_state(bot, slot, state); + } + // Restarting cannot fix credentials; the owner restarts it. + None if slot.state == BotState::AuthError => slot.restart_at = None, + None => { + if resumed && ran < self.settings.fresh_start_if_dies_within { + slot.fresh_next = true; + } + if ran >= STABLE_AFTER { + slot.backoff.reset(); + } + slot.restart_at = Some(Instant::now() + slot.backoff.next_delay()); + self.set_state(bot, slot, BotState::Backoff); + } + } + drop(inner); + self.wake(); + } +} + +struct Launch { + token_hash: String, + resumed: bool, + workspace: PathBuf, +} + +/// Command line and environment of spec 7.4, with a fresh bot token. +fn launch_spec( + daemon: &Daemon, + crew: &Crew, + bot: &BotRecord, + claude: &Claude, + fresh: bool, + size: crate::runtime::TermSize, +) -> io::Result<(SpawnSpec, Launch)> { + // settings.json and the rules are rewritten on every start. + let workspace = workspace::prepare_bot(daemon.workspace_env(), crew, bot)?; + let resumed = !fresh && workspace.join(STARTED_MARKER).exists(); + let token = secrets::random_token()?; + + let mut args: Vec = Vec::new(); + if resumed { + args.push("--continue".into()); + } + args.push("--settings".into()); + args.push(workspace.join(".claude").join("settings.json").into()); + args.push("--mcp-config".into()); + args.push(workspace.join(".botloft").join("mcp.json").into()); + + let mut env = platform::user_environment()?; + env.retain(|(name, _)| { + !name + .to_string_lossy() + .to_ascii_uppercase() + .starts_with("BOTLOFT_") + }); + for (name, value) in [ + ("BOTLOFT_BOT_ID", OsString::from(bot.id.as_str())), + ("BOTLOFT_BOT_TOKEN", OsString::from(&token)), + ("BOTLOFT_PORT", OsString::from(daemon.port.to_string())), + ("BOTLOFT_BIN", daemon.bin.clone().into_os_string()), + ("BOTLOFT_HOME", daemon.paths.home.clone().into_os_string()), + ] { + env.push((OsString::from(name), value)); + } + + let spec = SpawnSpec { + program: claude.path.clone(), + args, + cwd: workspace.clone(), + env, + size, + }; + Ok(( + spec, + Launch { + token_hash: TokenHash::of(&token).to_hex(), + resumed, + workspace, + }, + )) +} + +pub(super) fn mark_started(workspace: &Path) { + let marker = workspace.join(STARTED_MARKER); + if !marker.exists() + && let Err(err) = std::fs::write(&marker, b"") + { + warn!("could not write {}: {err}", marker.display()); + } +} + +/// Moves output into the terminal until the process exits, then reports it. +async fn pump( + daemon: Weak, + bot: BotId, + generation: u64, + terminal: Arc, + mut events: mpsc::Receiver, +) { + let mut cursor_query = CursorQueryWatch::default(); + let code = coalesce(&mut events, |data| { + terminal.push(generation, data); + if cursor_query.feed(data) + && let Some(daemon) = daemon.upgrade() + { + daemon + .supervisor + .reply(&bot, generation, CursorQueryWatch::ANSWER); + } + }) + .await; + if let Some(daemon) = daemon.upgrade() { + daemon.supervisor.on_exit(&bot, generation, code); + } +} + +/// Joins output arriving within [`COALESCE_WINDOW`] (up to +/// [`COALESCE_MAX`]) and hands each batch to `sink`. Returns the exit code. +pub(crate) async fn coalesce( + events: &mut mpsc::Receiver, + mut sink: impl FnMut(&[u8]), +) -> Option { + loop { + let mut batch = match events.recv().await { + Some(ProcessEvent::Output(data)) => BytesMut::from(&data[..]), + Some(ProcessEvent::Exited(code)) => return code, + None => return None, + }; + let deadline = Instant::now() + COALESCE_WINDOW; + let mut end = None; + while batch.len() < COALESCE_MAX { + match tokio::time::timeout_at(deadline, events.recv()).await { + Ok(Some(ProcessEvent::Output(data))) => batch.extend_from_slice(&data), + Ok(Some(ProcessEvent::Exited(code))) => { + end = Some(code); + break; + } + Ok(None) => { + end = Some(None); + break; + } + Err(_) => break, + } + } + sink(&batch); + if let Some(code) = end { + return code; + } + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + + use super::*; + + #[tokio::test(start_paused = true)] + async fn output_within_the_window_is_one_chunk() { + let (tx, mut rx) = mpsc::channel(8); + tx.send(ProcessEvent::Output(Bytes::from_static(b"ab"))) + .await + .expect("send"); + tx.send(ProcessEvent::Output(Bytes::from_static(b"cd"))) + .await + .expect("send"); + let late = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + tx.send(ProcessEvent::Output(Bytes::from_static(b"ef"))) + .await + .expect("send"); + tx.send(ProcessEvent::Exited(Some(3))).await.expect("send"); + }); + let mut chunks = Vec::new(); + let code = coalesce(&mut rx, |data| chunks.push(data.to_vec())).await; + late.await.expect("sender"); + assert_eq!(code, Some(3)); + assert_eq!(chunks, vec![b"abcd".to_vec(), b"ef".to_vec()]); + } +} diff --git a/crates/botloftd/src/terminal.rs b/crates/botloftd/src/terminal.rs new file mode 100644 index 00000000..a47dce5c --- /dev/null +++ b/crates/botloftd/src/terminal.rs @@ -0,0 +1,290 @@ +//! A bot's terminal output (spec 8): a ring buffer addressed by byte offset +//! within a process generation, and a broadcast of new output to every +//! attached client. + +use std::collections::VecDeque; +use std::sync::Mutex; + +use bytes::Bytes; +use tokio::sync::broadcast; + +/// Output chunks a slow client may lag behind before it has to re-attach. +const LIVE_BUFFER: usize = 256; + +/// Output of one generation, starting at `offset`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Chunk { + pub generation: u64, + pub offset: u64, + pub data: Bytes, +} + +/// Where a client picks up after `attach`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AttachPoint { + pub generation: u64, + /// Offset of the first byte of the replay. + pub offset: u64, + /// The client must clear its screen: it saw another generation, or the + /// bytes after its offset are no longer buffered. + pub reset: bool, +} + +pub struct Attachment { + pub point: AttachPoint, + /// Buffered bytes from `point.offset`, possibly empty. + pub replay: Bytes, + /// Everything pushed after the replay, with no gap and no overlap. + pub live: broadcast::Receiver, +} + +pub struct Terminal { + inner: Mutex, + live: broadcast::Sender, +} + +struct Inner { + generation: u64, + capacity: usize, + /// Offset of `buf[0]` within the generation. + start: u64, + buf: VecDeque, +} + +impl Inner { + fn end(&self) -> u64 { + self.start + self.buf.len() as u64 + } +} + +impl Terminal { + pub fn new(capacity: usize) -> Self { + let (live, _) = broadcast::channel(LIVE_BUFFER); + Self { + inner: Mutex::new(Inner { + generation: 0, + capacity: capacity.max(1), + start: 0, + buf: VecDeque::new(), + }), + live, + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { + self.inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub fn generation(&self) -> u64 { + self.lock().generation + } + + /// Starts a new generation with an empty buffer. + pub fn reset(&self, generation: u64) { + let mut inner = self.lock(); + inner.generation = generation; + inner.start = 0; + inner.buf.clear(); + } + + /// Appends output of `generation`; output of an older one is dropped. + pub fn push(&self, generation: u64, data: &[u8]) { + if data.is_empty() { + return; + } + let mut inner = self.lock(); + if inner.generation != generation { + return; + } + let offset = inner.end(); + inner.buf.extend(data); + let excess = inner.buf.len().saturating_sub(inner.capacity); + if excess > 0 { + inner.buf.drain(..excess); + inner.start += excess as u64; + } + // Sent under the lock so attach() never sees a chunk twice or misses one. + let _ = self.live.send(Chunk { + generation, + offset, + data: Bytes::copy_from_slice(data), + }); + } + + /// Replays from what the client last saw when possible (spec 8). + pub fn attach(&self, generation: Option, offset: Option) -> Attachment { + let inner = self.lock(); + let live = self.live.subscribe(); + let resumable = match (generation, offset) { + (Some(g), Some(o)) => g == inner.generation && (inner.start..=inner.end()).contains(&o), + _ => false, + }; + let from = if resumable { + offset.unwrap_or(inner.start) + } else { + reset_point(&inner) + }; + let skip = usize::try_from(from - inner.start).unwrap_or(usize::MAX); + let replay: Vec = inner.buf.iter().skip(skip).copied().collect(); + Attachment { + point: AttachPoint { + generation: inner.generation, + offset: from, + reset: !resumable, + }, + replay: Bytes::from(replay), + live, + } + } +} + +/// Finds the first cursor position query (`ESC [ 6 n`) in a byte stream, +/// even when it is split across chunks. +/// +/// ConPTY, as `portable-pty` creates it (`PSEUDOCONSOLE_INHERIT_CURSOR`), +/// asks the host terminal where the cursor is when it starts and holds the +/// child until it gets an answer. The daemon has no screen, so it answers +/// that first query itself; later queries are left to attached terminals. +#[derive(Debug, Default)] +pub struct CursorQueryWatch { + matched: usize, + seen: bool, +} + +impl CursorQueryWatch { + const QUERY: &'static [u8] = b"\x1b[6n"; + /// Row 1, column 1: a fresh screen. + pub const ANSWER: &'static [u8] = b"\x1b[1;1R"; + + /// Feeds output; true exactly once, when the first query completes. + pub fn feed(&mut self, data: &[u8]) -> bool { + if self.seen { + return false; + } + for &byte in data { + self.matched = if byte == Self::QUERY[self.matched] { + self.matched + 1 + } else if byte == Self::QUERY[0] { + 1 + } else { + 0 + }; + if self.matched == Self::QUERY.len() { + self.seen = true; + return true; + } + } + false + } +} + +/// Where a full replay starts: the whole generation when nothing was dropped, +/// otherwise after the first newline so it never opens mid escape sequence. +fn reset_point(inner: &Inner) -> u64 { + if inner.start == 0 { + return 0; + } + match inner.buf.iter().position(|&b| b == b'\n') { + Some(newline) => inner.start + newline as u64 + 1, + None => inner.start, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(bytes: &Bytes) -> &str { + std::str::from_utf8(bytes).expect("utf8") + } + + #[test] + fn replays_only_what_the_client_missed() { + let term = Terminal::new(1024); + term.reset(7); + term.push(7, b"hello "); + term.push(7, b"world"); + + let fresh = term.attach(None, None); + assert_eq!( + fresh.point, + AttachPoint { + generation: 7, + offset: 0, + reset: true + } + ); + assert_eq!(text(&fresh.replay), "hello world"); + + let resumed = term.attach(Some(7), Some(6)); + assert_eq!( + resumed.point, + AttachPoint { + generation: 7, + offset: 6, + reset: false + } + ); + assert_eq!(text(&resumed.replay), "world"); + + let up_to_date = term.attach(Some(7), Some(11)); + assert!(!up_to_date.point.reset); + assert!(up_to_date.replay.is_empty()); + } + + #[test] + fn another_generation_or_a_dropped_offset_resets() { + let term = Terminal::new(8); + term.reset(1); + term.push(1, b"ab\ncdefgh\nij"); + // Only the last 8 bytes stay: "defgh\nij". + let old = term.attach(Some(1), Some(0)); + assert!(old.point.reset); + assert_eq!(text(&old.replay), "ij", "cut after the first newline"); + + term.reset(2); + term.push(2, b"new"); + let stale = term.attach(Some(1), Some(3)); + assert_eq!( + stale.point, + AttachPoint { + generation: 2, + offset: 0, + reset: true + } + ); + assert_eq!(text(&stale.replay), "new"); + } + + #[test] + fn the_first_cursor_query_is_found_once_even_when_split() { + let mut watch = CursorQueryWatch::default(); + assert!(!watch.feed(b"banner \x1b[")); + assert!(watch.feed(b"6n more")); + assert!(!watch.feed(b"\x1b[6n"), "only the first one"); + + let mut near_miss = CursorQueryWatch::default(); + assert!(!near_miss.feed(b"\x1b[5n\x1b[?25l")); + assert!(near_miss.feed(b"\x1b\x1b[6n")); + } + + #[test] + fn live_output_follows_the_replay_without_gaps() { + let term = Terminal::new(64); + term.reset(3); + term.push(3, b"one"); + let mut attached = term.attach(None, None); + term.push(3, b"two"); + term.push(2, b"stale generation is dropped"); + + let chunk = attached.live.try_recv().expect("live chunk"); + assert_eq!( + chunk.offset, + attached.point.offset + attached.replay.len() as u64 + ); + assert_eq!(&chunk.data[..], b"two"); + assert!(attached.live.try_recv().is_err()); + } +} diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs new file mode 100644 index 00000000..b0ba2e0f --- /dev/null +++ b/crates/botloftd/src/workspace/files.rs @@ -0,0 +1,175 @@ +//! Contents of the files the daemon generates in a bot's workspace. + +use std::path::Path; + +use botloft_core::protocol::Crew; +use botloft_store::BotRecord; +use serde_json::{Value, json}; + +use crate::hooks::EVENTS as HOOKS; +use crate::paths::permission_rule_path; + +/// `.claude/settings.json` (spec 7.5). Hooks use exec form so Claude Code +/// runs `botloftd.exe` directly, without a shell. +pub fn settings_json(botloft_bin: &Path, botloft_home: &Path) -> Value { + let command = botloft_bin.to_string_lossy(); + let hooks: serde_json::Map = HOOKS + .iter() + .map(|(event, arg)| { + let hook = json!({ "type": "command", "command": command, "args": ["hook", arg] }); + ((*event).to_owned(), json!([{ "hooks": [hook] }])) + }) + .collect(); + let secrets = permission_rule_path(&botloft_home.join("secrets")); + json!({ + "crossSessionInbound": "accept", + "hooks": hooks, + "permissions": { "deny": [format!("Read({secrets}/**)")] }, + }) +} + +/// `.botloft/mcp.json` (spec 10). The token is expanded by Claude Code from +/// the bot's environment, so it is never written to disk. +pub fn mcp_json(port: u16) -> Value { + json!({ + "mcpServers": { + "botloft": { + "type": "http", + "url": format!("http://127.0.0.1:{port}/mcp"), + "headers": { "Authorization": "Bearer ${BOTLOFT_BOT_TOKEN}" }, + } + } + }) +} + +/// `CLAUDE.md`, written only when the workspace has none. The bot owns it. +pub const CLAUDE_MD: &str = "\ +# Memory + +This file is your long-term memory. It is loaded at the start of every +session, including after restarts. Keep decisions, context about ongoing +work and lessons learned here, and remove what is no longer true. +"; + +/// `.claude/rules/botloft.md`: who the bot is and how to work with its crew. +/// Regenerated whenever the bot or its crew changes. +pub fn rules_md(crew: &Crew, bot: &BotRecord, shared_dir: &Path) -> String { + let role = if bot.role.is_empty() { + "No role was given. Ask the owner if it is unclear what you should do." + } else { + bot.role.as_str() + }; + let instructions = if bot.instructions.is_empty() { + "None." + } else { + bot.instructions.as_str() + }; + format!( + " + +# Botloft + +You are **{name}** (`@{handle}`), a bot in the crew **{crew}**. Botloft keeps +you running all the time and delivers messages from the owner and from the +other bots of your crew. + +## Role + +{role} + +## Instructions from the owner + +{instructions} + +## Working with your crew + +- This folder is your workspace. `CLAUDE.md` here is your memory across restarts. +- Files for the whole crew go in the shared folder: `{shared}`. +- Messages from the owner and from other bots arrive as prompts that start + with `[botloft]`. The first line says who sent it; for a task it also has + the task id and the deadline. +- Use the `botloft` MCP tools to work with the crew: + - `crew_roster`: bots of your crew with handle, role and state. + - `send_message`: send a note, or a task with `kind: \"task\"`, to another bot by handle. + - `complete_task`: report the result of a task assigned to you; the requester is told. + - `my_tasks`: open tasks assigned to you or requested by you. +- You can only reach bots of your own crew. +- Do not wait in a loop for a reply. Finish your turn; the reply arrives as a + new message. +", + name = bot.name, + handle = bot.handle, + crew = crew.name, + shared = shared_dir.display(), + ) +} + +#[cfg(test)] +mod tests { + use botloft_core::ids::{BotId, CrewId}; + + use super::*; + + #[test] + fn settings_use_exec_form_hooks_and_deny_the_secrets_folder() { + let bin = Path::new(r"C:\Users\ana\AppData\Local\Botloft\bin\botloftd.exe"); + let home = Path::new(r"C:\Users\ana\AppData\Local\Botloft"); + let settings = settings_json(bin, home); + + assert_eq!(settings["crossSessionInbound"], "accept"); + let stop = &settings["hooks"]["StopFailure"][0]["hooks"][0]; + assert_eq!(stop["type"], "command"); + assert_eq!(stop["command"], bin.to_string_lossy().as_ref()); + assert_eq!(stop["args"], json!(["hook", "stop-failure"])); + assert_eq!( + settings["hooks"].as_object().map(|h| h.len()), + Some(HOOKS.len()) + ); + assert_eq!( + settings["permissions"]["deny"][0], + "Read(//c/Users/ana/AppData/Local/Botloft/secrets/**)" + ); + } + + #[test] + fn mcp_config_points_at_the_daemon_and_expands_the_token() { + let mcp = mcp_json(45710); + let server = &mcp["mcpServers"]["botloft"]; + assert_eq!(server["url"], "http://127.0.0.1:45710/mcp"); + assert_eq!( + server["headers"]["Authorization"], + "Bearer ${BOTLOFT_BOT_TOKEN}" + ); + } + + #[test] + fn rules_name_the_bot_crew_and_shared_folder() { + let crew = Crew { + id: CrewId::generate(), + name: "Site".to_owned(), + slug: "site".to_owned(), + paused: false, + created_at: 0, + archived_at: None, + }; + let bot = BotRecord { + id: BotId::generate(), + crew_id: crew.id.clone(), + name: "Revisor".to_owned(), + handle: "revisor".to_owned(), + slug: "revisor".to_owned(), + role: String::new(), + instructions: "Review every PR.".to_owned(), + color: "#FF7A59".to_owned(), + paused: false, + created_at: 0, + archived_at: None, + }; + let rules = rules_md(&crew, &bot, Path::new("/ws/site/shared")); + assert!(rules.contains("**Revisor** (`@revisor`)")); + assert!(rules.contains("crew **Site**")); + assert!(rules.contains("Review every PR.")); + assert!(rules.contains("Ask the owner")); + assert!(rules.contains("/ws/site/shared")); + } +} diff --git a/crates/botloftd/src/workspace/mod.rs b/crates/botloftd/src/workspace/mod.rs new file mode 100644 index 00000000..479bd3cb --- /dev/null +++ b/crates/botloftd/src/workspace/mod.rs @@ -0,0 +1,157 @@ +//! Crew folders and bot workspaces on disk (spec 5.1). Generation is +//! idempotent: running it again refreshes the generated files and keeps the +//! bot's own `CLAUDE.md`. + +mod files; + +use std::io; +use std::path::{Path, PathBuf}; + +use botloft_core::protocol::Crew; +use botloft_store::BotRecord; + +use crate::paths::Paths; + +/// What generated files need to know about the running daemon. +#[derive(Debug, Clone, Copy)] +pub struct WorkspaceEnv<'a> { + pub paths: &'a Paths, + /// Absolute path of `botloftd.exe`, run by the hooks. + pub bin: &'a Path, + pub port: u16, +} + +/// Creates the crew folder and its `shared` folder. +pub fn prepare_crew(paths: &Paths, crew_slug: &str) -> io::Result<()> { + std::fs::create_dir_all(paths.shared_dir(crew_slug)) +} + +/// Creates the workspace and writes every generated file. Returns the path. +pub fn prepare_bot(env: WorkspaceEnv<'_>, crew: &Crew, bot: &BotRecord) -> io::Result { + prepare_crew(env.paths, &crew.slug)?; + let dir = env.paths.bot_workspace(&crew.slug, &bot.slug); + std::fs::create_dir_all(dir.join(".claude").join("rules"))?; + std::fs::create_dir_all(dir.join(".botloft"))?; + + let memory = dir.join("CLAUDE.md"); + if !memory.exists() { + write_atomic(&memory, files::CLAUDE_MD.as_bytes())?; + } + write_json( + &dir.join(".claude").join("settings.json"), + &files::settings_json(env.bin, &env.paths.home), + )?; + write_json( + &dir.join(".botloft").join("mcp.json"), + &files::mcp_json(env.port), + )?; + write_rules(env.paths, crew, bot)?; + Ok(dir) +} + +/// Rewrites `.claude/rules/botloft.md` after the bot or its crew changed. +pub fn write_rules(paths: &Paths, crew: &Crew, bot: &BotRecord) -> io::Result<()> { + let dir = paths.bot_workspace(&crew.slug, &bot.slug); + let rules_dir = dir.join(".claude").join("rules"); + std::fs::create_dir_all(&rules_dir)?; + let text = files::rules_md(crew, bot, &paths.shared_dir(&crew.slug)); + write_atomic(&rules_dir.join("botloft.md"), text.as_bytes()) +} + +fn write_json(path: &Path, value: &serde_json::Value) -> io::Result<()> { + let mut text = serde_json::to_string_pretty(value).map_err(io::Error::other)?; + text.push('\n'); + write_atomic(path, text.as_bytes()) +} + +/// Writes through a temporary file and a rename, so a crash never leaves a +/// half-written file for Claude Code to read. +fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { + let mut tmp = path.as_os_str().to_owned(); + tmp.push(".tmp"); + let tmp = PathBuf::from(tmp); + std::fs::write(&tmp, contents)?; + std::fs::rename(&tmp, path) +} + +#[cfg(test)] +mod tests { + use botloft_core::ids::{BotId, CrewId}; + + use super::*; + + fn fixture() -> (tempfile::TempDir, Paths, Crew, BotRecord) { + let dir = tempfile::tempdir().expect("tempdir"); + let paths = Paths::new(dir.path().join("home"), dir.path().join("ws")); + let crew = Crew { + id: CrewId::generate(), + name: "Site".to_owned(), + slug: "site".to_owned(), + paused: false, + created_at: 0, + archived_at: None, + }; + let bot = BotRecord { + id: BotId::generate(), + crew_id: crew.id.clone(), + name: "Writer".to_owned(), + handle: "writer".to_owned(), + slug: "writer".to_owned(), + role: "writes docs".to_owned(), + instructions: String::new(), + color: "#FF7A59".to_owned(), + paused: false, + created_at: 0, + archived_at: None, + }; + (dir, paths, crew, bot) + } + + #[test] + fn prepares_every_generated_file() { + let (_dir, paths, crew, bot) = fixture(); + let env = WorkspaceEnv { + paths: &paths, + bin: Path::new("botloftd.exe"), + port: 45710, + }; + let ws = prepare_bot(env, &crew, &bot).expect("prepare"); + + assert_eq!(ws, paths.bot_workspace("site", "writer")); + assert!(paths.shared_dir("site").is_dir()); + for file in [ + "CLAUDE.md", + ".claude/settings.json", + ".claude/rules/botloft.md", + ".botloft/mcp.json", + ] { + assert!(ws.join(file).is_file(), "{file} missing"); + } + let settings: serde_json::Value = + serde_json::from_slice(&std::fs::read(ws.join(".claude/settings.json")).expect("read")) + .expect("valid json"); + assert_eq!(settings["crossSessionInbound"], "accept"); + } + + #[test] + fn regenerating_keeps_the_bot_memory_and_refreshes_rules() { + let (_dir, paths, crew, mut bot) = fixture(); + let env = WorkspaceEnv { + paths: &paths, + bin: Path::new("botloftd.exe"), + port: 45710, + }; + let ws = prepare_bot(env, &crew, &bot).expect("prepare"); + std::fs::write(ws.join("CLAUDE.md"), "my notes").expect("edit memory"); + + bot.name = "Lead Writer".to_owned(); + bot.handle = "lead-writer".to_owned(); + prepare_bot(env, &crew, &bot).expect("prepare again"); + + let memory = std::fs::read_to_string(ws.join("CLAUDE.md")).expect("memory"); + assert_eq!(memory, "my notes"); + let rules = std::fs::read_to_string(ws.join(".claude/rules/botloft.md")).expect("rules"); + assert!(rules.contains("`@lead-writer`")); + assert!(!ws.join(".claude/rules/botloft.md.tmp").exists()); + } +} diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs new file mode 100644 index 00000000..3968ac74 --- /dev/null +++ b/crates/botloftd/tests/common/mod.rs @@ -0,0 +1,225 @@ +//! Test harness: a daemon on an ephemeral port with temporary folders, and a +//! small JSON-RPC client over a real WebSocket. + +#![allow(dead_code)] // each test file uses a different part of the harness + +use std::collections::VecDeque; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use botloft_store::Store; +use botloftd::paths::Paths; +use botloftd::runtime::claude::Claude; +use botloftd::runtime::fake::FakeRuntime; +use botloftd::secrets::TokenHash; +use botloftd::server; +use botloftd::state::{Daemon, DaemonOptions}; +use botloftd::supervisor::{self, ClaudeSource, SupervisorSettings}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::net::{TcpListener, TcpStream}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::{self, Message}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +pub const TOKEN: &str = "test-owner-token"; +const WAIT: Duration = Duration::from_secs(5); + +pub struct TestDaemon { + pub addr: SocketAddr, + pub paths: Paths, + pub daemon: Arc, + pub runtime: FakeRuntime, + _dir: TempDir, +} + +/// Backoff short enough for tests that run on the real clock. +pub fn test_settings() -> SupervisorSettings { + SupervisorSettings { + claude: ClaudeSource::Fixed(Claude { + path: PathBuf::from(r"C:\Claude\claude.exe"), + version: "2.1.283".to_owned(), + }), + backoff_initial: Duration::from_millis(40), + backoff_max: Duration::from_millis(200), + fresh_start_if_dies_within: Duration::from_secs(15), + ring_buffer_bytes: 64 * 1024, + } +} + +/// A daemon with a fake runtime and an in-memory database, no server. +pub fn new_daemon(settings: SupervisorSettings) -> (Arc, FakeRuntime, Paths, TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let paths = Paths::new(dir.path().join("home"), dir.path().join("workspaces")); + let runtime = FakeRuntime::new(); + let daemon = Daemon::new(DaemonOptions { + paths: paths.clone(), + port: 45710, + bin: PathBuf::from(r"C:\Botloftinotloftd.exe"), + store: Store::open_in_memory().expect("store"), + owner_token: TokenHash::of(TOKEN), + runtime: Arc::new(runtime.clone()), + supervisor: settings, + }); + (daemon, runtime, paths, dir) +} + +impl TestDaemon { + /// Server only: bots are never started. + pub async fn start() -> Self { + Self::launch(false).await + } + + /// Server and supervisor, with bots running on a [`FakeRuntime`]. + pub async fn start_supervised() -> Self { + Self::launch(true).await + } + + async fn launch(supervised: bool) -> Self { + let (daemon, runtime, paths, dir) = new_daemon(test_settings()); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + if supervised { + tokio::spawn(supervisor::run(Arc::clone(&daemon))); + } + tokio::spawn(server::serve( + Arc::clone(&daemon), + listener, + std::future::pending(), + )); + Self { + addr, + paths, + daemon, + runtime, + _dir: dir, + } + } + + pub fn url(&self) -> String { + format!("ws://{}/rpc", self.addr) + } + + pub async fn client(&self) -> Client { + Client::connect(&self.url(), None).await.expect("connect") + } + + /// A client that already passed `session.hello`. + pub async fn session(&self) -> Client { + let mut client = self.client().await; + client.hello(TOKEN).await.expect("hello"); + client + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RpcFailure { + pub code: i64, + pub message: String, +} + +pub struct Client { + ws: WebSocketStream>, + next_id: u64, + notifications: VecDeque, +} + +impl Client { + pub async fn connect(url: &str, origin: Option<&str>) -> Result { + let mut request = url.into_client_request()?; + if let Some(origin) = origin { + let value = origin.parse().expect("header value"); + request.headers_mut().insert("Origin", value); + } + let (ws, _) = connect_async(request).await?; + Ok(Self { + ws, + next_id: 1, + notifications: VecDeque::new(), + }) + } + + pub async fn send_raw(&mut self, text: &str) { + self.ws.send(Message::text(text)).await.expect("send"); + } + + /// The next JSON frame, or `None` once the server closed the connection. + pub async fn recv(&mut self) -> Option { + loop { + let frame = tokio::time::timeout(WAIT, self.ws.next()) + .await + .expect("timed out waiting for the server"); + match frame { + Some(Ok(Message::Text(text))) => { + return Some(serde_json::from_str(text.as_str()).expect("server sent JSON")); + } + Some(Ok(Message::Close(_)) | Err(_)) | None => return None, + Some(Ok(_)) => {} + } + } + } + + /// Sends a request and waits for its response, queueing notifications + /// that arrive in between. `Value::Null` params are left out. + pub async fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + let mut request = json!({ "jsonrpc": "2.0", "id": id, "method": method }); + if !params.is_null() { + request["params"] = params; + } + self.send_raw(&request.to_string()).await; + loop { + let frame = self + .recv() + .await + .expect("connection closed before the response"); + if frame.get("id") == Some(&json!(id)) { + return match frame.get("error") { + Some(error) => Err(RpcFailure { + code: error["code"].as_i64().expect("error code"), + message: error["message"].as_str().unwrap_or_default().to_owned(), + }), + None => Ok(frame["result"].clone()), + }; + } + assert!(frame.get("method").is_some(), "unexpected frame {frame}"); + self.notifications.push_back(frame); + } + } + + pub async fn hello(&mut self, token: &str) -> Result { + self.call( + "session.hello", + json!({ "token": token, "client": { "name": "tests", "version": "0" }, "protocol": 1 }), + ) + .await + } + + /// Waits for the next notification named `method`, returning its params. + pub async fn notification(&mut self, method: &str) -> Value { + if let Some(pos) = self + .notifications + .iter() + .position(|n| n["method"] == method) + { + let found = self.notifications.remove(pos).expect("queued"); + return found["params"].clone(); + } + loop { + let frame = self.recv().await.expect("connection closed"); + if frame["method"] == method { + return frame["params"].clone(); + } + self.notifications.push_back(frame); + } + } + + /// True when the server closes the connection without sending more. + pub async fn closed(&mut self) -> bool { + self.recv().await.is_none() + } +} diff --git a/crates/botloftd/tests/rpc_crews_bots.rs b/crates/botloftd/tests/rpc_crews_bots.rs new file mode 100644 index 00000000..824b1fc7 --- /dev/null +++ b/crates/botloftd/tests/rpc_crews_bots.rs @@ -0,0 +1,254 @@ +//! Crews and bots over RPC, end to end: the M1 acceptance test (spec 17). + +mod common; + +use common::TestDaemon; +use serde_json::{Value, json}; + +const NOT_FOUND: i64 = -32002; +const CONFLICT: i64 = -32003; +const VALIDATION: i64 = -32004; + +fn read(path: std::path::PathBuf) -> String { + std::fs::read_to_string(&path).unwrap_or_else(|err| panic!("{}: {err}", path.display())) +} + +#[tokio::test] +async fn create_a_crew_and_a_bot_and_get_a_ready_workspace() { + let daemon = TestDaemon::start().await; + let mut app = daemon.session().await; + let mut watcher = daemon.session().await; + + let crew = app + .call("crews.create", json!({ "name": " Site da Loja " })) + .await + .expect("create crew"); + assert_eq!(crew["name"], "Site da Loja"); + assert_eq!(crew["slug"], "site-da-loja"); + assert!(crew["id"].as_str().is_some_and(|id| id.starts_with("crw_"))); + assert!(daemon.paths.shared_dir("site-da-loja").is_dir()); + assert_eq!(watcher.notification("crew.changed").await, crew); + + let bot = app + .call( + "bots.create", + json!({ + "crewId": crew["id"], + "name": "Revisão", + "role": "Reviews every change before it ships", + "instructions": "Be strict.\nExplain every finding.", + }), + ) + .await + .expect("create bot"); + assert_eq!(bot["handle"], "revisao"); + assert_eq!(bot["state"], "offline"); + assert_eq!(bot["color"], "#FF7A59"); + assert_eq!(watcher.notification("bot.changed").await, bot); + + let ws = daemon.paths.bot_workspace("site-da-loja", "revisao"); + assert_eq!(bot["workspace"], ws.to_string_lossy().as_ref()); + let settings: Value = + serde_json::from_str(&read(ws.join(".claude/settings.json"))).expect("json"); + assert_eq!(settings["crossSessionInbound"], "accept"); + assert_eq!( + settings["hooks"]["SessionStart"][0]["hooks"][0]["args"], + json!(["hook", "session-start"]) + ); + let deny = settings["permissions"]["deny"][0] + .as_str() + .expect("deny rule"); + assert!( + deny.starts_with("Read(//") && deny.ends_with("/secrets/**)"), + "{deny}" + ); + let mcp: Value = serde_json::from_str(&read(ws.join(".botloft/mcp.json"))).expect("json"); + assert_eq!( + mcp["mcpServers"]["botloft"]["url"], + "http://127.0.0.1:45710/mcp" + ); + let rules = read(ws.join(".claude/rules/botloft.md")); + assert!(rules.contains("**Revisão** (`@revisao`)") && rules.contains("crew **Site da Loja**")); + assert!(rules.contains("Explain every finding.")); + + let listed = app + .call("bots.list", json!({ "crewId": crew["id"] })) + .await + .expect("list"); + assert_eq!(listed, json!([bot])); + assert_eq!( + app.call("crews.list", json!(null)).await.expect("list"), + json!([crew]) + ); +} + +#[tokio::test] +async fn updates_refresh_the_rules_and_keep_the_bot_memory() { + let daemon = TestDaemon::start().await; + let mut app = daemon.session().await; + let crew = app + .call("crews.create", json!({ "name": "Docs" })) + .await + .expect("crew"); + let bot = app + .call("bots.create", json!({ "crewId": crew["id"], "name": "Writer", "role": "", "instructions": "", "color": "#5ec8ff" })) + .await + .expect("bot"); + assert_eq!(bot["color"], "#5EC8FF"); + let ws = daemon.paths.bot_workspace("docs", "writer"); + std::fs::write(ws.join("CLAUDE.md"), "notes the bot wrote").expect("edit memory"); + + let updated = app + .call("bots.update", json!({ "botId": bot["id"], "name": "Lead Writer", "instructions": "Short sentences." })) + .await + .expect("update"); + assert_eq!(updated["handle"], "lead-writer"); + assert_eq!(updated["slug"], "writer", "the folder never moves"); + assert_eq!(read(ws.join("CLAUDE.md")), "notes the bot wrote"); + assert!(read(ws.join(".claude/rules/botloft.md")).contains("Short sentences.")); + + app.call( + "crews.rename", + json!({ "crewId": crew["id"], "name": "Docs Team" }), + ) + .await + .expect("rename"); + assert!(read(ws.join(".claude/rules/botloft.md")).contains("crew **Docs Team**")); + + let paused = app + .call( + "bots.setPaused", + json!({ "botId": bot["id"], "paused": true }), + ) + .await + .expect("pause"); + assert_eq!(paused["paused"], true); + let crew = app + .call( + "crews.setPaused", + json!({ "crewId": crew["id"], "paused": true }), + ) + .await + .expect("pause"); + assert_eq!(crew["paused"], true); +} + +#[tokio::test] +async fn archiving_a_crew_archives_its_bots() { + let daemon = TestDaemon::start().await; + let mut app = daemon.session().await; + let crew = app + .call("crews.create", json!({ "name": "Temp" })) + .await + .expect("crew"); + let bot = app + .call( + "bots.create", + json!({ "crewId": crew["id"], "name": "A", "role": "", "instructions": "" }), + ) + .await + .expect("bot"); + + let archived = app + .call("crews.archive", json!({ "crewId": crew["id"] })) + .await + .expect("archive"); + assert!(archived["archivedAt"].is_i64()); + // The app hears its own changes too: first the bot's creation, then the + // archive cascade. + assert_eq!(app.notification("bot.changed").await, bot); + let cascaded = app.notification("bot.changed").await; + assert_eq!(cascaded["id"], bot["id"]); + assert_eq!(cascaded["state"], "archived"); + + assert_eq!( + app.call("crews.list", json!(null)).await.expect("list"), + json!([]) + ); + assert_eq!( + app.call("bots.list", json!(null)).await.expect("list"), + json!([]) + ); + let again = app + .call("crews.archive", json!({ "crewId": crew["id"] })) + .await + .expect("idempotent"); + assert_eq!(again, archived); + let err = app + .call( + "bots.create", + json!({ "crewId": crew["id"], "name": "B", "role": "", "instructions": "" }), + ) + .await + .expect_err("archived crew"); + assert_eq!(err.code, CONFLICT); + let err = app + .call("bots.update", json!({ "botId": bot["id"], "role": "x" })) + .await + .expect_err("archived bot"); + assert_eq!(err.code, CONFLICT); +} + +#[tokio::test] +async fn invalid_input_is_reported_with_the_validation_code() { + let daemon = TestDaemon::start().await; + let mut app = daemon.session().await; + + let err = app + .call("crews.create", json!({ "name": " " })) + .await + .expect_err("empty"); + assert_eq!(err.code, VALIDATION); + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let twin = app + .call("crews.create", json!({ "name": "OPS" })) + .await + .expect("same name"); + assert_eq!(twin["slug"], "ops-2"); + + let new_bot = + |name: &str| json!({ "crewId": crew["id"], "name": name, "role": "", "instructions": "" }); + app.call("bots.create", new_bot("Scout")) + .await + .expect("bot"); + let err = app + .call("bots.create", new_bot("scout!")) + .await + .expect_err("same handle"); + assert_eq!(err.code, VALIDATION); + assert!(err.message.contains("@scout"), "{}", err.message); + let shared = app + .call("bots.create", new_bot("Shared")) + .await + .expect("reserved folder"); + assert_eq!(shared["slug"], "shared-2"); + + let mut bad_color = new_bot("Painter"); + bad_color["color"] = json!("red"); + assert_eq!( + app.call("bots.create", bad_color) + .await + .expect_err("color") + .code, + VALIDATION + ); + + let unknown = json!({ "crewId": "crw_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0" }); + assert_eq!( + app.call("bots.list", unknown.clone()) + .await + .expect_err("missing") + .code, + NOT_FOUND + ); + assert_eq!( + app.call("crews.archive", unknown) + .await + .expect_err("missing") + .code, + NOT_FOUND + ); +} diff --git a/crates/botloftd/tests/rpc_session.rs b/crates/botloftd/tests/rpc_session.rs new file mode 100644 index 00000000..93a37977 --- /dev/null +++ b/crates/botloftd/tests/rpc_session.rs @@ -0,0 +1,130 @@ +//! Connection rules of `/rpc`: authentication, origins and error framing. + +mod common; + +use common::{Client, TOKEN, TestDaemon}; +use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_tungstenite::tungstenite; + +const NOT_AUTHENTICATED: i64 = -32001; + +#[tokio::test] +async fn health_answers_without_authentication() { + let daemon = TestDaemon::start().await; + let mut stream = tokio::net::TcpStream::connect(daemon.addr) + .await + .expect("connect"); + stream + .write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") + .await + .expect("write"); + let mut response = String::new(); + stream.read_to_string(&mut response).await.expect("read"); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + assert!(response.contains(r#""status":"ok""#), "{response}"); + assert!(response.contains(r#""protocol":1"#), "{response}"); +} + +#[tokio::test] +async fn hello_returns_the_daemon_version_and_protocol() { + let daemon = TestDaemon::start().await; + let mut client = daemon.client().await; + let result = client.hello(TOKEN).await.expect("hello"); + assert_eq!(result["protocol"], 1); + assert_eq!(result["daemonVersion"], env!("CARGO_PKG_VERSION")); +} + +#[tokio::test] +async fn any_method_before_hello_is_refused_and_closes_the_connection() { + let daemon = TestDaemon::start().await; + let mut client = daemon.client().await; + let err = client + .call("crews.list", json!(null)) + .await + .expect_err("refused"); + assert_eq!(err.code, NOT_AUTHENTICATED); + assert!(client.closed().await); +} + +#[tokio::test] +async fn a_wrong_token_is_refused_and_closes_the_connection() { + let daemon = TestDaemon::start().await; + let mut client = daemon.client().await; + let err = client.hello("not-the-token").await.expect_err("refused"); + assert_eq!(err.code, NOT_AUTHENTICATED); + assert!(client.closed().await); +} + +#[tokio::test] +async fn another_protocol_version_is_refused() { + let daemon = TestDaemon::start().await; + let mut client = daemon.client().await; + let err = client + .call( + "session.hello", + json!({ "token": TOKEN, "client": { "name": "t", "version": "0" }, "protocol": 2 }), + ) + .await + .expect_err("refused"); + assert_eq!(err.code, -32004); + assert!(client.closed().await); +} + +#[tokio::test] +async fn browser_origins_other_than_the_app_are_refused() { + let daemon = TestDaemon::start().await; + let refused = Client::connect(&daemon.url(), Some("https://evil.example")).await; + match refused { + Err(tungstenite::Error::Http(response)) => assert_eq!(response.status(), 403), + Err(other) => panic!("expected HTTP 403, got {other}"), + Ok(_) => panic!("a foreign origin must be refused"), + } + for origin in [ + "http://tauri.localhost", + "tauri://localhost", + "http://localhost:1420", + ] { + let mut client = Client::connect(&daemon.url(), Some(origin)) + .await + .unwrap_or_else(|err| panic!("{origin} refused: {err}")); + client.hello(TOKEN).await.expect("hello"); + } +} + +#[tokio::test] +async fn errors_after_hello_keep_the_connection_open() { + let daemon = TestDaemon::start().await; + let mut client = daemon.session().await; + + let err = client + .call("crews.explode", json!(null)) + .await + .expect_err("unknown"); + assert_eq!(err.code, -32601); + let err = client.hello(TOKEN).await.expect_err("second hello"); + assert_eq!(err.code, -32003); + let err = client + .call("crews.create", json!({ "title": "x" })) + .await + .expect_err("bad params"); + assert_eq!(err.code, -32602); + let err = client + .call("bots.list", json!({ "crewId": "not-an-id" })) + .await + .expect_err("bad id"); + assert_eq!(err.code, -32602); + + client.send_raw("{ not json").await; + let frame = client.recv().await.expect("parse error response"); + assert_eq!(frame["error"]["code"], -32700); + assert_eq!(frame["id"], serde_json::Value::Null); + + let status = client + .call("system.status", json!(null)) + .await + .expect("still open"); + assert_eq!(status["protocol"], 1); + assert!(status["uptimeMs"].as_i64().is_some()); + assert_eq!(status["claudeVersion"], serde_json::Value::Null); +} diff --git a/crates/botloftd/tests/rpc_terminal.rs b/crates/botloftd/tests/rpc_terminal.rs new file mode 100644 index 00000000..2da4fa4a --- /dev/null +++ b/crates/botloftd/tests/rpc_terminal.rs @@ -0,0 +1,190 @@ +//! Terminal, hooks and restarts over the network: the app's view of M2. + +mod common; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use botloftd::hooks::client::post; +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; + +fn decode(frame: &Value) -> Vec { + BASE64 + .decode(frame["data"].as_str().expect("data")) + .expect("base64") +} + +async fn crew_and_bot(app: &mut Client) -> Value { + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); + app.call("bots.create", bot).await.expect("bot") +} + +/// Waits for `bot.state` to report `state`, skipping earlier states. +async fn state(app: &mut Client, state: &str) -> Value { + loop { + let event = app.notification("bot.state").await; + if event["state"] == state { + return event; + } + } +} + +#[tokio::test] +async fn hooks_from_the_bot_change_the_state_the_app_sees() { + let daemon = TestDaemon::start_supervised().await; + let mut app = daemon.session().await; + let bot = crew_and_bot(&mut app).await; + let process = daemon.runtime.process(1).await; + let launching = state(&mut app, "launching").await; + assert_eq!(launching["botId"], bot["id"]); + + let token = process.env("BOTLOFT_BOT_TOKEN").expect("token"); + let port = daemon.addr.port(); + let body = json!({ "payload": { "hook_event_name": "SessionStart", "source": "startup" } }); + let body = serde_json::to_vec(&body).expect("json"); + tokio::task::spawn_blocking(move || post(port, "/hooks/session-start", &token, &body)) + .await + .expect("join") + .expect("hook accepted"); + let idle = state(&mut app, "idle").await; + assert_eq!(idle["generation"], launching["generation"]); + + let listed = app.call("bots.list", json!(null)).await.expect("list"); + assert_eq!(listed[0]["state"], "idle"); + assert_eq!(listed[0]["generation"], launching["generation"]); + + let wrong = + tokio::task::spawn_blocking(move || post(port, "/hooks/stop", "not-a-token", b"{}")) + .await + .expect("join"); + assert!(wrong.expect_err("refused").contains("401")); +} + +#[tokio::test] +async fn attach_replays_the_screen_then_streams_and_reattach_skips_what_was_seen() { + let daemon = TestDaemon::start_supervised().await; + let mut app = daemon.session().await; + let bot = crew_and_bot(&mut app).await; + let process = daemon.runtime.process(1).await; + state(&mut app, "launching").await; + process.output(b"hello\r\n").await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let attached = app + .call("terminal.attach", json!({ "botId": bot["id"] })) + .await + .expect("attach"); + assert_eq!(attached["reset"], true); + assert_eq!(attached["offset"], 0); + let replay = app.notification("terminal.data").await; + assert_eq!(decode(&replay), b"hello\r\n"); + + process.output(b"more").await; + let live = app.notification("terminal.data").await; + assert_eq!(live["offset"], 7); + assert_eq!(decode(&live), b"more"); + + let written = json!({ "botId": bot["id"], "data": BASE64.encode("hi\r") }); + app.call("terminal.write", written).await.expect("write"); + assert_eq!(process.input(), b"hi\r"); + let resized = json!({ "botId": bot["id"], "cols": 100, "rows": 30 }); + app.call("terminal.resize", resized).await.expect("resize"); + assert_eq!( + process.sizes().last().map(|s| (s.cols, s.rows)), + Some((100, 30)) + ); + + // Another window picks up where it left off: nothing to replay. + let mut other = daemon.session().await; + let resume = json!({ "botId": bot["id"], "generation": attached["generation"], "offset": 11 }); + let again = other + .call("terminal.attach", resume) + .await + .expect("reattach"); + assert_eq!(again["reset"], false); + assert_eq!(again["offset"], 11); + + app.call("terminal.detach", json!({ "botId": bot["id"] })) + .await + .expect("detach"); + process.output(b"!").await; + let seen_by_other = other.notification("terminal.data").await; + assert_eq!(decode(&seen_by_other), b"!"); +} + +#[tokio::test] +async fn a_restart_shows_up_as_a_new_generation() { + let daemon = TestDaemon::start_supervised().await; + let mut app = daemon.session().await; + let bot = crew_and_bot(&mut app).await; + let first = daemon.runtime.process(1).await; + let before = state(&mut app, "launching").await; + app.call("terminal.attach", json!({ "botId": bot["id"] })) + .await + .expect("attach"); + + let restarted = app + .call("bots.restart", json!({ "botId": bot["id"], "fresh": true })) + .await + .expect("restart"); + assert_eq!(restarted["id"], bot["id"]); + let second = daemon.runtime.process(2).await; + assert!(first.killed()); + let after = state(&mut app, "launching").await; + assert_ne!(after["generation"], before["generation"]); + + second.output(b"new screen").await; + let data = app.notification("terminal.data").await; + assert_eq!(data["generation"], after["generation"]); + assert_eq!(data["offset"], 0); +} + +#[tokio::test] +async fn terminal_calls_check_the_bot_and_its_process() { + let daemon = TestDaemon::start().await; + let mut app = daemon.session().await; + let bot = crew_and_bot(&mut app).await; + + let write = json!({ "botId": bot["id"], "data": BASE64.encode("x") }); + let err = app + .call("terminal.write", write) + .await + .expect_err("not running"); + assert_eq!(err.code, -32003); + let bad = json!({ "botId": bot["id"], "data": "***" }); + assert_eq!( + app.call("terminal.write", bad) + .await + .expect_err("base64") + .code, + -32004 + ); + let huge = json!({ "botId": bot["id"], "cols": 0, "rows": 30 }); + assert_eq!( + app.call("terminal.resize", huge) + .await + .expect_err("size") + .code, + -32004 + ); + let missing = json!({ "botId": "bot_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0" }); + assert_eq!( + app.call("terminal.attach", missing) + .await + .expect_err("missing") + .code, + -32002 + ); + + let paused = json!({ "botId": bot["id"], "paused": true }); + app.call("bots.setPaused", paused).await.expect("pause"); + let err = app + .call("bots.restart", json!({ "botId": bot["id"] })) + .await + .expect_err("paused"); + assert_eq!(err.code, -32003); +} diff --git a/crates/botloftd/tests/supervisor.rs b/crates/botloftd/tests/supervisor.rs new file mode 100644 index 00000000..7fe45ff6 --- /dev/null +++ b/crates/botloftd/tests/supervisor.rs @@ -0,0 +1,304 @@ +//! Supervisor behavior on a FakeRuntime and a paused clock (spec 7 and 16). + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use botloft_core::ids::BotId; +use botloft_core::protocol::{ + BotIdParams, BotState, BotsCreateParams, BotsRestartParams, BotsSetPausedParams, + CrewsCreateParams, +}; +use botloftd::runtime::fake::{FakeProcess, FakeRuntime}; +use botloftd::service::{bots, crews}; +use botloftd::state::Daemon; +use botloftd::supervisor::{self, Hook}; +use common::{new_daemon, test_settings}; + +struct Setup { + daemon: Arc, + runtime: FakeRuntime, + bot: BotId, + _dir: tempfile::TempDir, +} + +async fn setup() -> Setup { + let (daemon, runtime, _paths, dir) = new_daemon(test_settings()); + let crew = crews::create(&daemon, CrewsCreateParams { name: "Ops".into() }).expect("crew"); + let bot = bots::create( + &daemon, + BotsCreateParams { + crew_id: crew.id, + name: "Scout".into(), + role: String::new(), + instructions: String::new(), + color: None, + }, + ) + .expect("bot"); + tokio::spawn(supervisor::run(Arc::clone(&daemon))); + Setup { + daemon, + runtime, + bot: bot.id, + _dir: dir, + } +} + +impl Setup { + fn state(&self) -> BotState { + self.daemon + .supervisor + .status(&self.bot) + .map(|(state, _)| state) + .expect("slot") + } + + /// Waits (on the paused clock) until the bot reaches `state`. + async fn until(&self, state: BotState) { + for _ in 0..600 { + if self.daemon.supervisor.status(&self.bot).map(|(s, _)| s) == Some(state) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("bot never reached {state:?}; it is {:?}", self.state()); + } + + /// Sends a hook the way `/hooks` does: with the process's own token. + fn hook(&self, process: &FakeProcess, hook: Hook) { + let token = process.env("BOTLOFT_BOT_TOKEN").expect("token in env"); + let (bot, generation) = self + .daemon + .supervisor + .hook_owner(&token) + .expect("live token"); + self.daemon.supervisor.on_hook(&bot, generation, hook); + } +} + +#[tokio::test(start_paused = true)] +async fn a_new_bot_starts_with_the_spec_command_line_and_a_clean_environment() { + let s = setup().await; + let process = s.runtime.process(1).await; + s.until(BotState::Launching).await; + + let args = process.args(); + assert_eq!( + args[0], "--settings", + "first start has nothing to --continue" + ); + assert!(args[1].ends_with("settings.json") && args[2] == "--mcp-config"); + assert!(process.spec.cwd.ends_with("scout")); + assert_eq!( + process.env("BOTLOFT_BOT_ID").as_deref(), + Some(s.bot.as_str()) + ); + assert_eq!(process.env("BOTLOFT_PORT").as_deref(), Some("45710")); + assert!(process.env("BOTLOFT_HOME").is_some() && process.env("BOTLOFT_BIN").is_some()); + assert_eq!( + process.env("CLAUDECODE"), + None, + "no Claude session variables leak in" + ); + assert_eq!(process.env("CLAUDE_CODE_MESSAGING_SOCKET"), None); +} + +#[tokio::test(start_paused = true)] +async fn hooks_drive_the_state_and_input_answers_a_permission_prompt() { + let s = setup().await; + let process = s.runtime.process(1).await; + s.until(BotState::Launching).await; + + s.hook(&process, Hook::SessionStart { inbox: None }); + assert_eq!(s.state(), BotState::Idle); + s.hook(&process, Hook::PromptSubmit); + assert_eq!(s.state(), BotState::Busy); + s.hook( + &process, + Hook::Notification { + kind: "permission_prompt".into(), + }, + ); + assert_eq!(s.state(), BotState::NeedsApproval); + s.daemon + .supervisor + .write(&s.bot, "1".into()) + .expect("write"); + assert_eq!(s.state(), BotState::Busy); + assert_eq!(process.input(), b"1"); + s.hook( + &process, + Hook::StopFailure { + error: "rate_limit".into(), + }, + ); + assert_eq!(s.state(), BotState::RateLimited); + s.hook(&process, Hook::Stop); + assert_eq!(s.state(), BotState::Idle); +} + +#[tokio::test(start_paused = true)] +async fn a_crash_restarts_with_backoff_and_continue_and_a_new_token() { + let s = setup().await; + let first = s.runtime.process(1).await; + s.until(BotState::Launching).await; + s.hook(&first, Hook::SessionStart { inbox: None }); + let old_token = first.env("BOTLOFT_BOT_TOKEN").expect("token"); + let (_, first_generation) = s.daemon.supervisor.status(&s.bot).expect("slot"); + + tokio::time::sleep(Duration::from_secs(60)).await; + first.exit(1).await; + s.until(BotState::Backoff).await; + + let second = s.runtime.process(2).await; + s.until(BotState::Launching).await; + assert_eq!(second.args()[0], "--continue"); + assert!( + s.daemon.supervisor.hook_owner(&old_token).is_none(), + "old token is revoked" + ); + let (_, generation) = s.daemon.supervisor.status(&s.bot).expect("slot"); + assert!(generation > first_generation); +} + +#[tokio::test(start_paused = true)] +async fn a_resumed_session_that_dies_at_once_comes_back_fresh() { + let s = setup().await; + let first = s.runtime.process(1).await; + s.until(BotState::Launching).await; + s.hook(&first, Hook::SessionStart { inbox: None }); + first.exit(0).await; + + let resumed = s.runtime.process(2).await; + assert_eq!(resumed.args()[0], "--continue"); + resumed.exit(1).await; + + let fresh = s.runtime.process(3).await; + assert_eq!(fresh.args()[0], "--settings"); +} + +#[tokio::test(start_paused = true)] +async fn auth_errors_wait_for_the_owner() { + let s = setup().await; + let process = s.runtime.process(1).await; + s.until(BotState::Launching).await; + s.hook( + &process, + Hook::StopFailure { + error: "authentication_failed".into(), + }, + ); + assert_eq!(s.state(), BotState::AuthError); + process.exit(1).await; + + tokio::time::sleep(Duration::from_secs(600)).await; + assert_eq!(s.runtime.processes().len(), 1, "no restart loop"); + assert_eq!(s.state(), BotState::AuthError); + + let restart = BotsRestartParams { + bot_id: s.bot.clone(), + fresh: None, + }; + bots::restart(&s.daemon, restart).expect("restart"); + s.runtime.process(2).await; + s.until(BotState::Launching).await; +} + +#[tokio::test(start_paused = true)] +async fn pausing_stops_the_bot_and_resuming_starts_it() { + let s = setup().await; + let first = s.runtime.process(1).await; + s.until(BotState::Launching).await; + + let pause = |paused| BotsSetPausedParams { + bot_id: s.bot.clone(), + paused, + }; + bots::set_paused(&s.daemon, pause(true)).expect("pause"); + s.until(BotState::Offline).await; + assert!(first.killed()); + tokio::time::sleep(Duration::from_secs(60)).await; + assert_eq!(s.runtime.processes().len(), 1); + + bots::set_paused(&s.daemon, pause(false)).expect("resume"); + s.runtime.process(2).await; + s.until(BotState::Launching).await; +} + +#[tokio::test(start_paused = true)] +async fn archiving_stops_the_bot_for_good() { + let s = setup().await; + let process = s.runtime.process(1).await; + s.until(BotState::Launching).await; + bots::archive( + &s.daemon, + BotIdParams { + bot_id: s.bot.clone(), + }, + ) + .expect("archive"); + s.until(BotState::Archived).await; + assert!(process.killed()); + tokio::time::sleep(Duration::from_secs(60)).await; + assert_eq!(s.runtime.processes().len(), 1); +} + +#[tokio::test(start_paused = true)] +async fn a_fresh_restart_replaces_the_process_without_continue() { + let s = setup().await; + let first = s.runtime.process(1).await; + s.until(BotState::Launching).await; + s.hook(&first, Hook::SessionStart { inbox: None }); + + let restart = BotsRestartParams { + bot_id: s.bot.clone(), + fresh: Some(true), + }; + bots::restart(&s.daemon, restart).expect("restart"); + let second = s.runtime.process(2).await; + assert!(first.killed()); + assert_eq!(second.args()[0], "--settings"); +} + +#[tokio::test(start_paused = true)] +async fn output_lands_in_the_terminal_and_the_size_carries_over() { + let s = setup().await; + let first = s.runtime.process(1).await; + s.until(BotState::Launching).await; + // ConPTY's startup cursor query is answered by the daemon itself. + first.output(b"\x1b[6nhello\r\n").await; + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(first.input(), b"\x1b[1;1R"); + + let terminal = s.daemon.supervisor.terminal(&s.bot); + let attached = terminal.attach(None, None); + assert_eq!(&attached.replay[..], b"\x1b[6nhello\r\n"); + + let size = botloftd::runtime::TermSize { cols: 80, rows: 24 }; + s.daemon.supervisor.resize(&s.bot, size); + assert_eq!(first.sizes(), vec![size]); + first.exit(1).await; + let second = s.runtime.process(2).await; + assert_eq!(second.spec.size, size); +} + +#[tokio::test(start_paused = true)] +async fn a_failed_spawn_backs_off_and_retries() { + let (daemon, runtime, _paths, _dir) = new_daemon(test_settings()); + runtime.fail_next_spawn(); + let crew = crews::create(&daemon, CrewsCreateParams { name: "Ops".into() }).expect("crew"); + let params = BotsCreateParams { + crew_id: crew.id, + name: "Scout".into(), + role: String::new(), + instructions: String::new(), + color: None, + }; + let bot = bots::create(&daemon, params).expect("bot"); + tokio::spawn(supervisor::run(Arc::clone(&daemon))); + runtime.process(1).await; + let state = daemon.supervisor.status(&bot.id).map(|(state, _)| state); + assert_eq!(state, Some(BotState::Launching)); +} diff --git a/docs/spec.md b/docs/spec.md index 16bb5f93..5fb94580 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -107,7 +107,9 @@ Tipos do protocolo ficam em `botloft-core` e são exportados para TypeScript com Workspaces ficam num caminho curto e visível para o usuário abrir no Explorer e para reduzir estouro do limite de 260 caracteres (bots rodam `npm install`). -Nomes de pasta de crew e bot são slugs gerados na criação e **não mudam** quando o nome de exibição muda. +Nomes de pasta de crew e bot são slugs gerados na criação e **não mudam** quando o nome de exibição muda. Slug: ASCII minúsculo com hífens, acentos transliterados (`Revisão` -> `revisao`), no máximo 32 caracteres, nunca um nome de dispositivo do Windows (`con`, `lpt1`...) e nunca `shared` para bot. Colisão ganha sufixo (`docs-2`), inclusive com slug de item arquivado ou pasta que já exista no disco. + +O **handle** do bot (`@revisao`) é derivado do nome pela mesma regra, acompanha renomeações e é único entre os bots ativos da crew; um nome que gere handle já usado é recusado com erro de validação. ### 5.1 Arquivos gerados em cada workspace @@ -121,7 +123,7 @@ Nomes de pasta de crew e bot são slugs gerados na criação e **não mudam** qu mcp.json config MCP do bot (gerado pelo daemon) ``` -Identidade e instruções vão em `.claude/rules/botloft.md` e não na linha de comando: o texto pode ser longo e a linha de comando do Windows é limitada. **Verificar** na versão alvo do Claude Code que regras sem frontmatter `paths` são carregadas sempre. Plano B: `--append-system-prompt` com texto curto apontando para o arquivo. +Identidade e instruções vão em `.claude/rules/botloft.md` e não na linha de comando: o texto pode ser longo e a linha de comando do Windows é limitada. Regras sem frontmatter `paths` são carregadas no início de toda sessão, com a mesma prioridade de `.claude/CLAUDE.md` (confirmado na documentação oficial, ver seção 19). Plano B, se isso mudar: `--append-system-prompt` com texto curto apontando para o arquivo. ## 6. Configuração (`config.toml`) @@ -177,29 +179,38 @@ default_deadline_minutes = 120 | hook `UserPromptSubmit` | `busy` | | hook `Stop` | `idle` | | hook `StopFailure` com `rate_limit` | `rate_limited` | -| hook `StopFailure` com `authentication_failed` | `auth_error` | +| hook `StopFailure` com `authentication_failed` ou `oauth_org_not_allowed` | `auth_error` | +| hook `StopFailure` com outro `error` | `idle` (o turno acabou, a sessão segue) | | hook `Notification` com `permission_prompt` | `needs_approval` | | input do usuário depois de `needs_approval` | `busy` | | saída do processo | `backoff` (ou `offline`/`archived` se foi pedido) | +Campos lidos do JSON do stdin (documentados): `error` no `StopFailure`, `notification_type` no `Notification`. Hooks de uma generation antiga são ignorados. Todo processo novo emite `bot.state {botId, state, generation}`, mesmo que o nome do estado não mude, porque a generation mudou. + ### 7.3 Regras - Bots não pausados sempre rodam. O supervisor reconcilia no boot e a cada 5 s. - Backoff exponencial com jitter entre `restart_backoff_initial_ms` e `restart_backoff_max_ms`; zera após 10 min de sessão estável. -- Relançamento usa `--continue` para retomar a conversa. Se a sessão retomada morrer em menos de `fresh_start_if_dies_within_s`, o próximo start vem sem `--continue`. +- Relançamento usa `--continue` para retomar a conversa. Se a sessão retomada morrer em menos de `fresh_start_if_dies_within_s`, o próximo start vem sem `--continue`. O primeiro start de um bot não usa `--continue`: o daemon grava `.botloft/started` no workspace no primeiro `SessionStart` e só a partir daí retoma. `bots.restart {fresh: true}` também começa conversa nova. +- Mudanças em nome, papel ou instruções regravam as regras na hora, mas o bot só as lê no próximo start; o daemon não reinicia o bot sozinho. - `auth_error` não entra em loop de restart: fica parado até o owner pedir `bots.restart`. - Cada processo de bot entra num **Job Object** com `KILL_ON_JOB_CLOSE`. Se o daemon morrer, a árvore de processos dos bots morre junto e não sobra `claude.exe` órfão. ### 7.4 Spawn -1. Resolver o binário: `claude_path` ou PATH (`claude.exe` do instalador nativo; `claude.cmd` do npm roda via `cmd.exe /d /s /c`). -2. `probe`: `claude --version` e exigir **>= 2.1.234** (inbox via named pipe no Windows nativo). +1. Resolver o binário: `claude_path` ou PATH. Só o `claude.exe` nativo roda. O `claude.cmd` do npm é recusado com erro claro: passar por `cmd.exe /d /s /c` estraga o quoting dos argumentos na PTY, e a documentação recomenda o instalador nativo. O PATH é separado só por `;`, como o Windows faz; aspas soltas numa entrada (comum em PATHs reais) não escondem as entradas seguintes. +2. `probe`: `claude --version` e exigir **>= 2.1.234** (inbox via named pipe no Windows nativo). Sem Claude utilizável, os bots ficam `offline`, `system.status.runtimeError` diz o motivo e o daemon tenta de novo a cada 30 s. 3. Gerar os arquivos da seção 5.1. -4. Comando (confirmar flags com `claude --help` na versão alvo): - `claude [--continue] --settings \.claude\settings.json --mcp-config \.botloft\mcp.json` -5. Ambiente: `BOTLOFT_BOT_ID`, `BOTLOFT_BOT_TOKEN`, `BOTLOFT_PORT`, `BOTLOFT_BIN` (caminho absoluto do `botloftd.exe`). +4. Comando: `claude [--continue] --settings \.claude\settings.json --mcp-config \.botloft\mcp.json`. O `--settings` aponta para o mesmo arquivo que o Claude Code já lê como settings do projeto; a documentação garante que um hook definido em dois arquivos roda uma vez só, e o `--settings` dá precedência ao `crossSessionInbound` sobre as settings do usuário. +5. Ambiente: o bloco padrão do usuário (`CreateEnvironmentBlock`, o mesmo de um logon novo), **não** o ambiente do daemon. Um daemon iniciado de dentro de uma sessão do Claude Code herda `CLAUDECODE`, `CLAUDE_CODE_MESSAGING_SOCKET`, `ANTHROPIC_BASE_URL` e outras variáveis da sessão, que fariam o bot se achar filho dela. Por cima vão `BOTLOFT_BOT_ID`, `BOTLOFT_BOT_TOKEN`, `BOTLOFT_PORT`, `BOTLOFT_BIN` (caminho absoluto do `botloftd.exe`) e `BOTLOFT_HOME` (onde o hook grava `logs\hook.log`). 6. PTY com cwd no workspace e tamanho vindo do último `terminal.resize` (padrão 120x32). +### 7.4.1 Primeira execução de cada bot + +Numa sessão interativa, o Claude Code segura **todos** os hooks até o dono aceitar o diálogo de confiança da pasta (documentado; confirmado com 2.1.283). Na primeira execução o bot fica em `launching`, com o diálogo no terminal, e só vai para `idle` quando alguém escolhe "Yes, I trust this folder" (a opção pré-selecionada é "No, exit"). Depois disso a confiança fica gravada para aquela pasta. Avisos de primeira execução vindos da configuração global do usuário também aparecem no terminal (ex.: extensão do Chrome detectada, novo renderizador). + +A confiança de uma pasta pai cobre as subpastas (fora de repositório git). O onboarding do app (M4) deve pedir ao dono para confiar uma vez na raiz dos workspaces, para que bots novos não parem no diálogo. Workspaces dentro de um repositório git herdam a confiança da raiz do repositório. + ### 7.5 `settings.json` gerado ```json @@ -214,14 +225,15 @@ default_deadline_minutes = 120 "SessionEnd": [{ "hooks": [{ "type": "command", "command": "", "args": ["hook", "session-end"] }] }] }, "permissions": { - "deny": ["Read(/secrets/**)"] + "deny": ["Read(//c/Users//AppData/Local/Botloft/secrets/**)"] } } ``` - Hooks em **exec form** (`args` presente): o Claude Code executa o binário direto, sem Git Bash nem PowerShell. Some o problema de quoting, de `curl` e de path com barra invertida. +- O `command` do exec form precisa ser um `.exe` de verdade; shims `.cmd`/`.bat` exigem shell. `botloftd.exe` atende. - `crossSessionInbound: accept` é obrigatório: o daemon não é processo filho da sessão, então sem isso a mensagem pode ficar retida esperando aprovação. -- Caminhos são absolutos e escritos com `/` dentro das regras de permissão. **Verificar** a sintaxe de path das permission rules no Windows. +- Caminho absoluto em permission rule usa o prefixo `//` e a forma POSIX que o Claude Code aplica no Windows: `C:\Users\ana\...` vira `//c/Users/ana/...` (letra do drive em minúscula). Uma barra só (`/caminho`) é relativa à origem do settings, não à raiz, e não protegeria nada. O daemon converte `BOTLOFT_HOME` para essa forma ao gerar o arquivo. ### 7.6 Subcomando `botloftd hook ` @@ -232,13 +244,16 @@ default_deadline_minutes = 120 ## 8. Terminal -- Leitura da PTY numa thread dedicada por bot, enviando blocos por canal para a task async. +- Cada processo tem três threads: leitura da PTY, escrita (a escrita nunca bloqueia quem chama) e espera pelo fim do processo. A leitura manda blocos por canal para a task async. Quando o processo termina, o daemon fecha o pseudoconsole; sem isso o ConPTY nunca entrega EOF. - Coalescência: junta leituras por até 8 ms ou 32 KiB antes de emitir. -- Cada bot tem um ring buffer de `ring_buffer_bytes` com cursor `offset` (bytes desde o início da generation). +- **Handshake do ConPTY:** o `portable-pty` cria o pseudoconsole com `PSEUDOCONSOLE_INHERIT_CURSOR`, e com isso o ConPTY pergunta a posição do cursor (`ESC[6n`) ao iniciar e segura o processo filho até ouvir a resposta. O daemon responde `ESC[1;1R` à primeira consulta de cada generation; as seguintes ficam com o terminal do app (confirmado com Claude Code real: sem a resposta, a tela fica vazia para sempre). +- Cada bot tem um ring buffer de `ring_buffer_bytes` com cursor `offset` (bytes desde o início da generation). O buffer fica depois que o processo morre, para o dono ver a última tela. +- Generations são únicas entre bots e entre reinícios do daemon (o contador começa no horário de boot em ms), para um cliente nunca confundir um processo novo com um que já viu. - `terminal.attach {botId, generation?, offset?}`: - mesma generation e offset ainda no buffer: responde `{generation, offset, reset: false}` e envia só o que falta; - - senão: `{reset: true}` e envia o buffer inteiro, cortado no primeiro `\n` para não começar no meio de uma sequência de escape. -- Dados vão em `terminal.data {botId, generation, offset, data}` com `data` em base64 (bytes crus; o xterm.js recebe `Uint8Array` e resolve UTF-8 quebrado entre blocos). + - senão: `{reset: true}` e envia o buffer inteiro; se o buffer já descartou o começo da generation, corta no primeiro `\n` para não começar no meio de uma sequência de escape. +- A resposta do `terminal.attach` sai antes do primeiro `terminal.data`. `terminal.detach {botId}` para o envio. `terminal.write {botId, data}` e `terminal.resize {botId, cols, rows}` (1 a 1000) respondem `null`; escrever num bot sem processo dá `-32003`. +- Dados vão em `terminal.data {botId, generation, offset, data}` com `data` em base64 (bytes crus; o xterm.js recebe `Uint8Array` e resolve UTF-8 quebrado entre blocos). Um `terminal.data` com generation nova é uma tela nova: o cliente limpa antes de escrever. Um cliente que atrasa pode ver o offset pular; ele percebe (offset diferente do esperado) e refaz o attach com o que tem. - Vários clientes podem assistir ao mesmo bot. Qualquer cliente autenticado pode escrever (MVP só tem o owner). ## 9. Mensagens e entrega @@ -264,7 +279,8 @@ default_deadline_minutes = 120 1. `{"type":"auth","token":""}` (**obrigatório** no Windows) 2. `{"type":"user","message":{"role":"user","content":""}}` - Fechar a conexão depois do flush. -- **Verificar** o formato exato da linha de mensagem contra a documentação de cross-session messaging na versão alvo e cobrir com teste de integração manual (checklist no M3). +- A documentação oficial confirma a linha de auth, a obrigatoriedade dela no Windows e o limite de 30 s, mas **não documenta** a linha de mensagem (item 2). Ela precisa ser confirmada com teste real no M3 antes de o courier depender dela. +- Limites documentados do lado do Claude Code: mensagem de até ~1 milhão de caracteres, no máximo 50 mensagens aceitas na fila, rajadas recusadas e repetições idênticas descartadas. O courier deve respeitar isso (uma entrega por vez por bot). ### 9.3 Envelope @@ -312,22 +328,23 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co ### 11.1 Sessão - Primeiro request obrigatório: `session.hello {token, client: {name, version}, protocol: 1}` -> `{daemonVersion, protocol}`. -- Qualquer outro método antes do hello: erro `-32001` e a conexão fecha. -- `Origin` aceito: `http://tauri.localhost`, `tauri://localhost` e `http://localhost:1420` (dev). +- Qualquer outro método antes do hello: erro `-32001` e a conexão fecha. Token errado também dá `-32001`; `protocol` diferente dá `-32004`; nos dois casos a conexão fecha. O hello tem que chegar em até 10 s. +- `Origin` aceito: `http://tauri.localhost`, `tauri://localhost` e `http://localhost:1420` (dev). Qualquer outro `Origin` recebe HTTP 403 antes do upgrade. Sem `Origin` (cliente nativo, testes) é aceito: navegadores sempre mandam o header, e o token continua obrigatório. +- Um cliente lento que deixa acumular mais de 1024 notificações é desconectado e recarrega o estado ao reconectar. ### 11.2 Métodos (MVP) | Método | Params | Result | |---|---|---| -| `system.status` | | versão, uptime, versão do claude, backlog de entrega | +| `system.status` | | versão, uptime, versão do claude, `runtimeError` (por que os bots não sobem), backlog de entrega (M3) | | `crews.list` | | `Crew[]` | | `crews.create` | `name` | `Crew` | | `crews.rename` | `crewId, name` | `Crew` | | `crews.setPaused` | `crewId, paused` | `Crew` | | `crews.archive` | `crewId` | `Crew` | | `bots.list` | `crewId?` | `Bot[]` | -| `bots.create` | `crewId, name, role, instructions` | `Bot` | -| `bots.update` | `botId, name?, role?, instructions?` | `Bot` | +| `bots.create` | `crewId, name, role, instructions, color?` | `Bot` | +| `bots.update` | `botId, name?, role?, instructions?, color?` | `Bot` | | `bots.setPaused` | `botId, paused` | `Bot` | | `bots.restart` | `botId, fresh?` | `Bot` | | `bots.archive` | `botId` | `Bot` | @@ -355,6 +372,8 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `-32004` | validação | | `-32005` | runtime indisponível (claude ausente ou versão antiga) | +Além desses, os códigos padrão do JSON-RPC: `-32700` (JSON inválido), `-32600` (request inválido), `-32601` (método desconhecido), `-32602` (params inválidos) e `-32603` (erro interno; a mensagem não traz corpo de mensagem nem token). `crews.archive` e `bots.archive` são idempotentes. + ## 12. Dados (SQLite) Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations numeradas em `botloft-store/migrations/NNNN_nome.sql`, versão em `PRAGMA user_version`. Tempo em milissegundos Unix (`INTEGER`). Arquivamento é lógico (`archived_at`). @@ -362,7 +381,7 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | Tabela | Colunas principais | |---|---| | `crews` | `id, name, slug, paused, created_at, archived_at` | -| `bots` | `id, crew_id, name, handle, slug, role, instructions, paused, token_hash, created_at, archived_at` | +| `bots` | `id, crew_id, name, handle, slug, role, instructions, color, paused, token_hash, created_at, archived_at` | | `messages` | `id, crew_id, from_kind (owner/bot/system), from_bot_id, to_bot_id, kind (note/task/result/system), body, task_id, created_at` | | `deliveries` | `id, message_id, bot_id, state, attempts, next_attempt_at, lease_until, last_error, updated_at` | | `tasks` | `id, crew_id, requester_bot_id, assignee_bot_id, status, deadline_at, hops, origin_task_id, result, created_at, updated_at` | @@ -374,8 +393,9 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations - Daemon escuta **só em 127.0.0.1** no MVP. - Token do owner: 32 bytes aleatórios em `secrets\owner.token`, ACL com acesso só para o SID do usuário atual (DACL protegida, sem herança). -- Token de bot: 32 bytes aleatórios. No banco fica só o **SHA-256** (`token_hash`); o valor cru existe apenas no ambiente do processo do bot. Novo token a cada generation. +- Token de bot: 32 bytes aleatórios, novo a cada generation. O valor cru existe apenas no ambiente do processo do bot; o daemon guarda só o **SHA-256**, e só em memória: todo processo de bot morre junto com o daemon (Job Object), então nenhum token sobrevive a um reinício e não há motivo para gravá-lo. A coluna `bots.token_hash` fica sem uso. - Logs nunca registram tokens, conteúdo de mensagens nem saída de terminal em nível `info`. Bots podem manipular dados sensíveis (inclusive de saúde); o daemon trata corpo de mensagem como dado pessoal. +- `log_level` vale só para os crates do Botloft; dependências ficam em `warn`, porque em `debug`/`trace` a pilha de WebSocket registra frames, que podem conter mensagens. `RUST_LOG` sobrepõe tudo e é só para depuração local. - Isolamento entre bots é cooperativo (mesmo usuário do Windows). Documentar isso no README sem prometer sandbox. ## 14. Integração com o Windows @@ -389,7 +409,7 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | Encerramento | `ctrl_c`, `ctrl_close`, `ctrl_shutdown`, `ctrl_logoff` do Tokio: parar courier, sinalizar bots, flush do banco | | Caminhos longos | manifesto `longPathAware` no daemon e no app; usar APIs com `\\?\` ao apagar árvores de workspace | | Arquivo em uso | arquivar bot só apaga workspace depois do processo encerrado; retentar exclusão se bloqueado | -| Instância única | mutex nomeado `Local\Botloft.Daemon`; se já existir, sair com erro claro | +| Instância única | lock exclusivo em `\botloftd.lock` (`File::try_lock`), solto pelo sistema quando o processo termina, mesmo em crash; se já estiver preso, sair com erro claro. É um lock por pasta de dados, e não um mutex de nome fixo, para o daemon de dev (`BOTLOFT_HOME`) rodar ao lado do instalado | | Porta ocupada | se 45710 estiver em uso por outro processo, sair com erro (sem porta alternativa no MVP) | ## 15. App desktop @@ -468,12 +488,19 @@ Identidade: o mascote do Botloft é uma chama com olhos, desenhada em vetor em ` ## 19. Pontos a verificar na versão alvo do Claude Code -| Item | Seção | -|---|---| -| Exec form de hooks (`args`) no Windows | 7.5 | -| Formato das linhas no named pipe do inbox | 9.2 | -| Carregamento de `.claude/rules/*.md` sem frontmatter | 5.1 | -| Expansão `${VAR}` em headers do `mcp.json` | 10 | -| Sintaxe de caminho Windows em permission rules | 7.5 | -| Flags `--continue`, `--settings`, `--mcp-config` | 7.4 | -| Payload do `StopFailure` e do `Notification` | 7.2 | +Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Confirmado" quer dizer documentado; o teste real na versão alvo continua no checklist do marco indicado. + +| Item | Seção | Resultado | Teste real | +|---|---|---|---| +| Exec form de hooks (`args`) no Windows | 7.5 | Confirmado (`hooks`) e **testado com 2.1.283**: `botloftd.exe hook session-start` rodou direto, sem shell | feito (M2) | +| Linha de auth, 30 s e variáveis do inbox | 9.2 | Confirmado (`cross-session-messaging`): `CLAUDE_CODE_MESSAGING_SOCKET`, `CLAUDE_CODE_MESSAGING_TOKEN`, auth obrigatória no Windows, >= 2.1.234 | M3 | +| Formato da linha de mensagem no inbox | 9.2 | **Não documentado** | M3, obrigatório | +| `crossSessionInbound` | 7.5 | Confirmado: valores `accept`, `hold`, `refuse`; `refuse` em settings de projeto vence tudo | M3 | +| Carregamento de `.claude/rules/*.md` sem frontmatter | 5.1 | Confirmado (`memory`) e **testado com 2.1.283**: o bot respondeu nome, handle e crew tirados das regras | feito (M2) | +| Expansão `${VAR}` em headers do `mcp.json` | 10 | Confirmado (`mcp`); alguns nomes de credencial conhecidos são lidos vazios, `BOTLOFT_BOT_TOKEN` não é um deles | M3 | +| Sintaxe de caminho Windows em permission rules | 7.5 | Confirmado (`permissions`) e **testado com 2.1.283**: ler `secrets\owner.token` deu "File is in a directory that is denied by your permission settings" | feito (M2) | +| Flags `--continue`, `--mcp-config` | 7.4 | Confirmado (`cli-reference`); aceitas pelo 2.1.283 | `--continue` retomando conversa real: pendente | +| Flag `--settings ` | 7.4 | Ausente da tabela de CLI, mas aceita pelo 2.1.283 e os hooks do arquivo rodaram | feito (M2) | +| stdout do `SessionStart` vira contexto | 7.6 | Confirmado (`hooks`); o subcomando nunca escreve no stdout | feito (M2) | +| `StopFailure` (`rate_limit`, `authentication_failed`) e `Notification` (`permission_prompt`) | 7.2 | Campos documentados: `error` e `notification_type`. `SessionStart`, `UserPromptSubmit` e `Stop` **testados com 2.1.283** (`launching` -> `idle` -> `busy` -> `idle`) | disparar `StopFailure` e `permission_prompt` reais: pendente | +| Confiança da pasta segura os hooks | 7.4.1 | Confirmado (`hooks`, `permissions`) e **visto com 2.1.283**: diálogo na primeira execução, bot em `launching` até aceitar | feito (M2) |