From 9a0106ef13a19af86c67dd9bdcf9e5927d9c83fd Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:58:05 -0300 Subject: [PATCH 1/7] docs(spec): record Claude Code behaviors checked against official docs Section 19 now lists what the official documentation confirms and which real test (M2/M3) still has to run. Two corrections came out of it: - Absolute paths in permission rules need the `//` prefix and the POSIX form Claude Code uses on Windows (`//c/Users/...`). A single leading slash is relative to the settings file, so the old wording would have left the secrets folder readable. - The inbox message line format is not documented; the courier must not depend on it before the M3 real test. --- docs/spec.md | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 16bb5f93..5e102c63 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -121,7 +121,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`) @@ -214,14 +214,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 ` @@ -264,7 +265,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 @@ -468,12 +470,18 @@ 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`): com `args`, o binário roda direto, sem shell; `command` precisa ser `.exe` | 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`): carregadas sempre | 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`): `//c/...` em forma POSIX; spec corrigida | M2 | +| Flags `--continue`, `--mcp-config` | 7.4 | Confirmado (`cli-reference`) | M2 | +| Flag `--settings ` | 7.4 | Citada em outras páginas (ex.: `cross-session-messaging`), ausente da tabela de CLI; confirmar com `claude --help` | M2 | +| stdout do `SessionStart` vira contexto | 7.6 | Confirmado (`hooks`) | M2 | +| `StopFailure` (`rate_limit`, `authentication_failed`) e `Notification` (`permission_prompt`) | 7.2 | Eventos e valores de matcher confirmados; nome do campo no JSON do stdin a confirmar | M2 | From 126c646c7742ff340ed4a7edf364a81a6430e180 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:01:36 -0300 Subject: [PATCH 2/7] feat(core): add prefixed IDs, slugs, avatar colors and protocol types - Prefixed ULID ids (crw_, bot_, msg_, dlv_, tsk_) that validate their prefix on parse and deserialize - Slugs and handles from display names: transliterated, max 32 chars, Windows device names avoided, numeric suffix for collisions - Avatar palette and #RRGGBB validation (spec 15.3) - Field validation for names, roles and instructions - M1 protocol types (hello, status, crews and bots) exported to app/src/lib/protocol.gen.ts by the export_bindings test; ts-rs is a dev-dependency only, and CI fails if the generated file is stale --- .github/workflows/ci.yml | 2 + Cargo.lock | 814 +++++++++++++++++++- app/src/lib/protocol.gen.ts | 167 ++++ crates/botloft-core/Cargo.toml | 8 + crates/botloft-core/src/avatar.rs | 45 ++ crates/botloft-core/src/ids.rs | 138 ++++ crates/botloft-core/src/lib.rs | 21 +- crates/botloft-core/src/protocol/export.rs | 148 ++++ crates/botloft-core/src/protocol/methods.rs | 145 ++++ crates/botloft-core/src/protocol/mod.rs | 50 ++ crates/botloft-core/src/protocol/model.rs | 64 ++ crates/botloft-core/src/slug.rs | 114 +++ crates/botloft-core/src/validate.rs | 78 ++ 13 files changed, 1772 insertions(+), 22 deletions(-) create mode 100644 app/src/lib/protocol.gen.ts create mode 100644 crates/botloft-core/src/avatar.rs create mode 100644 crates/botloft-core/src/ids.rs create mode 100644 crates/botloft-core/src/protocol/export.rs create mode 100644 crates/botloft-core/src/protocol/methods.rs create mode 100644 crates/botloft-core/src/protocol/mod.rs create mode 100644 crates/botloft-core/src/protocol/model.rs create mode 100644 crates/botloft-core/src/slug.rs create mode 100644 crates/botloft-core/src/validate.rs 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/Cargo.lock b/Cargo.lock index 9a638764..38249e82 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,49 @@ 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 = [ + "rusqlite", + "tempfile", + "thiserror 2.0.21", +] [[package]] name = "botloftd" version = "0.1.0" dependencies = [ + "anyhow", + "axum", "botloft-core", "botloft-store", + "clap", + "dirs", + "futures-util", + "getrandom 0.4.3", + "hex", + "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 +486,17 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[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 +509,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 +565,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 +630,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 +673,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 +745,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 +823,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]] @@ -755,6 +1005,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" @@ -1046,6 +1318,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1202,11 +1475,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 +1569,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 +1597,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1501,6 +1811,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 +1987,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 +2051,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 +2100,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" @@ -1870,6 +2224,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[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" @@ -2109,6 +2472,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" @@ -2303,6 +2672,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" @@ -2402,18 +2780,64 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "raw-window-handle" -version = "0.6.2" +name = "rand" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "rand" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "bitflags 2.13.2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", ] [[package]] @@ -2509,6 +2933,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 +2973,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 +3159,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 +3199,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" @@ -2784,6 +3275,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,8 +3304,28 @@ 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]] @@ -2801,6 +3334,16 @@ 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 +3426,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 +3485,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 +3702,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.119", "tauri-utils", "thiserror 2.0.21", @@ -3265,6 +3826,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 +3848,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 +3897,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 +3962,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 +4125,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3534,10 +4164,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 +4201,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 +4261,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 +4327,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 +4380,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 +4398,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" @@ -4271,7 +5039,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.21", @@ -4329,6 +5097,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..712a848f --- /dev/null +++ b/app/src/lib/protocol.gen.ts @@ -0,0 +1,167 @@ +// 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, +/** + * 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, }; + +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, }; + +/** 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 }; +} + +/** Params of every server notification. */ +export interface RpcNotifications { + "crew.changed": Crew; + "bot.changed": Bot; +} + +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..8dec231d --- /dev/null +++ b/crates/botloft-core/src/protocol/export.rs @@ -0,0 +1,148 @@ +//! 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::(); + + 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.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); + 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..bb9a650f --- /dev/null +++ b/crates/botloft-core/src/protocol/methods.rs @@ -0,0 +1,145 @@ +//! 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, +} + +#[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, +} diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs new file mode 100644 index 00000000..c4f19a22 --- /dev/null +++ b/crates/botloft-core/src/protocol/mod.rs @@ -0,0 +1,50 @@ +//! 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; + +pub use methods::*; +pub use model::*; + +/// 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"; +} + +/// Server notification method names. +pub mod notification { + pub const CREW_CHANGED: &str = "crew.changed"; + pub const BOT_CHANGED: &str = "bot.changed"; +} + +/// 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..cbfc7bdc --- /dev/null +++ b/crates/botloft-core/src/protocol/model.rs @@ -0,0 +1,64 @@ +//! 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, + /// 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, +} 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()); + } +} From 5e7cfd0a7b0b89f4b3367cb025ee70db0a7a07bc Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:03:49 -0300 Subject: [PATCH 3/7] feat(store): add SQLite store with migrations, crews and bots - WAL, foreign keys and a 5 s busy timeout on every connection (spec 12) - Numbered migrations tracked in PRAGMA user_version; a database from a newer build is refused instead of being modified - 0001_init: crews, bots and settings. Handles are unique among a crew's active bots (partial index); slugs stay unique forever so an archived bot's folder is never reused. Bots get a color column for the avatar (spec 15.3) - Crew archiving cascades to its bots in one transaction --- Cargo.lock | 1 + crates/botloft-store/Cargo.toml | 6 + crates/botloft-store/migrations/.gitkeep | 0 crates/botloft-store/migrations/0001_init.sql | 38 +++ crates/botloft-store/src/bots.rs | 262 ++++++++++++++++++ crates/botloft-store/src/crews.rs | 152 ++++++++++ crates/botloft-store/src/lib.rs | 128 +++++++++ crates/botloft-store/src/migrate.rs | 60 ++++ 8 files changed, 647 insertions(+) delete mode 100644 crates/botloft-store/migrations/.gitkeep create mode 100644 crates/botloft-store/migrations/0001_init.sql create mode 100644 crates/botloft-store/src/bots.rs create mode 100644 crates/botloft-store/src/crews.rs create mode 100644 crates/botloft-store/src/migrate.rs diff --git a/Cargo.lock b/Cargo.lock index 38249e82..b19d84bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,6 +286,7 @@ dependencies = [ name = "botloft-store" version = "0.1.0" dependencies = [ + "botloft-core", "rusqlite", "tempfile", "thiserror 2.0.21", 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 { .. }))); + } +} From 57cbe07299beed9187a58f9e6caca2e73378bff5 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:08:51 -0300 Subject: [PATCH 4/7] feat(daemon): add config, data paths, owner token and owner-only ACLs - config.toml with the spec 6 defaults; unknown keys are rejected - BOTLOFT_HOME / %LOCALAPPDATA%\Botloft and %USERPROFILE%\Botloft resolution, plus the //c/... form Claude Code expects in permission rules - Owner token: 32 random bytes in secrets\owner.token, only its SHA-256 kept in memory, compared in constant time - platform: protected DACL with a single user SID on Windows (tested against the real ACL), 0700/0600 elsewhere; Windows console close/logoff/shutdown signals - Single instance through an exclusive lock file per data folder instead of a fixed-name mutex, so a dev daemon can run next to an installed one; spec 14 updated --- crates/botloftd/Cargo.toml | 23 ++ crates/botloftd/src/config.rs | 213 ++++++++++++++++++ crates/botloftd/src/lib.rs | 8 + crates/botloftd/src/paths.rs | 130 +++++++++++ crates/botloftd/src/platform/mod.rs | 73 ++++++ crates/botloftd/src/platform/unix.rs | 28 +++ crates/botloftd/src/platform/windows.rs | 149 ++++++++++++ crates/botloftd/src/platform/windows/tests.rs | 85 +++++++ crates/botloftd/src/secrets.rs | 124 ++++++++++ docs/spec.md | 2 +- 10 files changed, 834 insertions(+), 1 deletion(-) create mode 100644 crates/botloftd/src/config.rs create mode 100644 crates/botloftd/src/lib.rs create mode 100644 crates/botloftd/src/paths.rs create mode 100644 crates/botloftd/src/platform/mod.rs create mode 100644 crates/botloftd/src/platform/unix.rs create mode 100644 crates/botloftd/src/platform/windows.rs create mode 100644 crates/botloftd/src/platform/windows/tests.rs create mode 100644 crates/botloftd/src/secrets.rs diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index a83867cd..99430394 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -8,8 +8,31 @@ repository.workspace = true authors.workspace = true [dependencies] +anyhow = "1.0.104" +axum = { version = "0.8.9", features = ["ws"] } botloft-core.workspace = true botloft-store.workspace = true +clap = { version = "4.6.7", features = ["derive"] } +dirs = "7.0.0" +futures-util = "0.3.34" +getrandom = "0.4.3" +hex = "0.4.3" +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"] } + +[dev-dependencies] +tempfile = "3.27.0" +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/lib.rs b/crates/botloftd/src/lib.rs new file mode 100644 index 00000000..aab1f391 --- /dev/null +++ b/crates/botloftd/src/lib.rs @@ -0,0 +1,8 @@ +//! `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 paths; +pub mod platform; +pub mod secrets; 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..abb01527 --- /dev/null +++ b/crates/botloftd/src/platform/mod.rs @@ -0,0 +1,73 @@ +//! 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::restrict_to_current_user; +#[cfg(windows)] +pub use windows::restrict_to_current_user; + +/// 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 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..80f7abe9 --- /dev/null +++ b/crates/botloftd/src/platform/unix.rs @@ -0,0 +1,28 @@ +//! Unix fallbacks, so the daemon builds and its tests run off Windows. + +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 => {} + } +} diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs new file mode 100644 index 00000000..729af750 --- /dev/null +++ b/crates/botloftd/src/platform/windows.rs @@ -0,0 +1,149 @@ +//! Windows ACLs and console signals. + +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/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/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/docs/spec.md b/docs/spec.md index 5e102c63..5040b17a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -391,7 +391,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 From 00423abdc1733161fca3ae048aad98bb2c1032eb Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:09:48 -0300 Subject: [PATCH 5/7] feat(daemon): generate crew folders and bot workspaces Writes the files of spec 5.1 into \\: - .claude/settings.json: exec-form hooks calling botloftd.exe, crossSessionInbound=accept and a deny rule for the secrets folder in the //c/... form Claude Code matches on Windows - .botloft/mcp.json: the local MCP endpoint with the bot token expanded from the environment, so it never touches disk - .claude/rules/botloft.md: identity, role, instructions, shared folder and how to use the crew tools; regenerated on every change - CLAUDE.md: created once and then left to the bot Every write goes through a temp file and a rename. --- crates/botloftd/src/lib.rs | 1 + crates/botloftd/src/workspace/files.rs | 184 +++++++++++++++++++++++++ crates/botloftd/src/workspace/mod.rs | 157 +++++++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 crates/botloftd/src/workspace/files.rs create mode 100644 crates/botloftd/src/workspace/mod.rs diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index aab1f391..c278bc5b 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -6,3 +6,4 @@ pub mod config; pub mod paths; pub mod platform; pub mod secrets; +pub mod workspace; diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs new file mode 100644 index 00000000..5e16bb90 --- /dev/null +++ b/crates/botloftd/src/workspace/files.rs @@ -0,0 +1,184 @@ +//! 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::paths::permission_rule_path; + +/// Hook events the daemon listens to, with the `botloftd hook` argument. +const HOOKS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("Stop", "stop"), + ("StopFailure", "stop-failure"), + ("Notification", "notification"), + ("SessionEnd", "session-end"), +]; + +/// `.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()); + } +} From 025ce175be718938a4b4ecb0b31dee5aeafe0321 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:18:48 -0300 Subject: [PATCH 6/7] feat(rpc): serve crews and bots over WebSocket JSON-RPC - /rpc: JSON-RPC 2.0 over WebSocket. session.hello with the owner token must come first (10 s); anything else, a wrong token or another protocol version closes the connection. Browser origins other than the app get 403; clients without Origin still need the token - /health for the app's daemon check - crews.* and bots.* (spec 11.2) run under the store lock, write the workspace before the database and broadcast crew.changed and bot.changed to every connection; a client that falls 1024 events behind is dropped and reloads on reconnect - Handles follow the bot's name and must be unique among active bots of the crew; folders keep their slug. Bots get a palette color unless one is given - Integration tests run a real daemon on an ephemeral port with a WebSocket client: the M1 acceptance flow (create a crew and a bot via RPC and get a ready workspace), updates, archiving, auth and errors Spec: color in bots.create/update and the bots table, slug and handle rules, Origin and hello details, standard JSON-RPC codes. --- crates/botloftd/src/lib.rs | 4 + crates/botloftd/src/rpc/dispatch.rs | 45 +++++ crates/botloftd/src/rpc/jsonrpc.rs | 168 ++++++++++++++++ crates/botloftd/src/rpc/mod.rs | 154 ++++++++++++++ crates/botloftd/src/server.rs | 73 +++++++ crates/botloftd/src/service/bots.rs | 184 +++++++++++++++++ crates/botloftd/src/service/crews.rs | 102 ++++++++++ crates/botloftd/src/service/mod.rs | 79 ++++++++ crates/botloftd/src/state.rs | 94 +++++++++ crates/botloftd/tests/common/mod.rs | 178 +++++++++++++++++ crates/botloftd/tests/rpc_crews_bots.rs | 254 ++++++++++++++++++++++++ crates/botloftd/tests/rpc_session.rs | 130 ++++++++++++ docs/spec.md | 18 +- 13 files changed, 1477 insertions(+), 6 deletions(-) create mode 100644 crates/botloftd/src/rpc/dispatch.rs create mode 100644 crates/botloftd/src/rpc/jsonrpc.rs create mode 100644 crates/botloftd/src/rpc/mod.rs create mode 100644 crates/botloftd/src/server.rs create mode 100644 crates/botloftd/src/service/bots.rs create mode 100644 crates/botloftd/src/service/crews.rs create mode 100644 crates/botloftd/src/service/mod.rs create mode 100644 crates/botloftd/src/state.rs create mode 100644 crates/botloftd/tests/common/mod.rs create mode 100644 crates/botloftd/tests/rpc_crews_bots.rs create mode 100644 crates/botloftd/tests/rpc_session.rs diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index c278bc5b..527fa32b 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -5,5 +5,9 @@ pub mod config; pub mod paths; pub mod platform; +pub mod rpc; pub mod secrets; +pub mod server; +pub mod service; +pub mod state; pub mod workspace; diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs new file mode 100644 index 00000000..00b53fce --- /dev/null +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -0,0 +1,45 @@ +//! 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}; +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::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}"), + )), + } +} + +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..1067e07d --- /dev/null +++ b/crates/botloftd/src/rpc/mod.rs @@ -0,0 +1,154 @@ +//! 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; + +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 { + run(stream, &outbox, events, &daemon).await; + } + 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, +) { + loop { + let frame = tokio::select! { + message = stream.next() => match message { + Some(Ok(Message::Text(text))) => handle(daemon, &text), + 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; + } + } +} + +fn handle(daemon: &Daemon, text: &str) -> 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 result = dispatch::dispatch(daemon, &request.method, request.params); + let id = request.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)), + }; + jsonrpc::notification(name, params.unwrap_or(Value::Null)) +} diff --git a/crates/botloftd/src/server.rs b/crates/botloftd/src/server.rs new file mode 100644 index 00000000..f88192d6 --- /dev/null +++ b/crates/botloftd/src/server.rs @@ -0,0 +1,73 @@ +//! HTTP server on 127.0.0.1: `GET /health` and the `/rpc` WebSocket. +//! `/mcp` and `/hooks` join in M2 and M3. + +use std::future::Future; +use std::sync::Arc; + +use axum::Router; +use axum::extract::{State, WebSocketUpgrade}; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use botloft_core::protocol::PROTOCOL_VERSION; +use serde_json::json; +use tokio::net::TcpListener; + +use crate::rpc; +use crate::state::Daemon; + +/// 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; + +pub fn router(daemon: Arc) -> Router { + Router::new() + .route("/health", get(health)) + .route("/rpc", get(rpc_upgrade)) + .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..a7f412d5 --- /dev/null +++ b/crates/botloftd/src/service/bots.rs @@ -0,0 +1,184 @@ +//! `bots.*` operations. + +use botloft_core::ids::{BotId, CrewId}; +use botloft_core::protocol::{ + Bot, BotIdParams, BotState, BotsCreateParams, BotsListParams, 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)?; + Ok(changed(daemon, &crew, record)) +} + +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. + 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)?; + Ok(changed(daemon, &crew, record)) +} + +/// Archives the bot. Archiving twice is not an error. The workspace stays on +/// disk until the runtime can stop the process first (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)?; + Ok(changed(daemon, &crew, record)) +} + +/// The protocol view of a stored bot. Until the runtime exists (M2) every +/// active bot is `offline`. +pub(crate) fn to_protocol(daemon: &Daemon, crew: &Crew, record: BotRecord) -> Bot { + let state = if record.archived_at.is_some() { + BotState::Archived + } else { + BotState::Offline + }; + 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, + 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 +} + +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..5139aba8 --- /dev/null +++ b/crates/botloftd/src/service/crews.rs @@ -0,0 +1,102 @@ +//! `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())); + } + 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())); + 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..32e19da9 --- /dev/null +++ b/crates/botloftd/src/service/mod.rs @@ -0,0 +1,79 @@ +//! 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; + +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: None, + } +} + +/// [`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/state.rs b/crates/botloftd/src/state.rs new file mode 100644 index 00000000..5b5217f2 --- /dev/null +++ b/crates/botloftd/src/state.rs @@ -0,0 +1,94 @@ +//! State shared by every connection: the store, the event bus and what the +//! daemon knows about itself. + +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard}; +use std::time::Instant; + +use botloft_core::protocol::{Bot, Crew}; +use botloft_store::Store; +use tokio::sync::broadcast; + +use crate::paths::Paths; +use crate::secrets::TokenHash; +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), +} + +/// 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 struct Daemon { + pub paths: Paths, + pub port: u16, + pub bin: PathBuf, + store: Mutex, + owner_token: TokenHash, + events: broadcast::Sender, + started: Instant, +} + +impl Daemon { + pub fn new(options: DaemonOptions) -> Self { + let (events, _) = broadcast::channel(EVENT_BUFFER); + Self { + 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/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs new file mode 100644 index 00000000..7200941e --- /dev/null +++ b/crates/botloftd/tests/common/mod.rs @@ -0,0 +1,178 @@ +//! 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::secrets::TokenHash; +use botloftd::server; +use botloftd::state::{Daemon, DaemonOptions}; +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, + _dir: TempDir, +} + +impl TestDaemon { + pub async fn start() -> Self { + let dir = tempfile::tempdir().expect("tempdir"); + let paths = Paths::new(dir.path().join("home"), dir.path().join("workspaces")); + let daemon = Arc::new(Daemon::new(DaemonOptions { + paths: paths.clone(), + port: 45710, + bin: PathBuf::from(r"C:\Botloft\bin\botloftd.exe"), + store: Store::open_in_memory().expect("store"), + owner_token: TokenHash::of(TOKEN), + })); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + tokio::spawn(server::serve(daemon, listener, std::future::pending())); + Self { + addr, + paths, + _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/docs/spec.md b/docs/spec.md index 5040b17a..4e38ea5b 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 @@ -314,8 +316,9 @@ 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) @@ -328,8 +331,8 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `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` | @@ -357,6 +360,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`). @@ -364,7 +369,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` | @@ -378,6 +383,7 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations - 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. - 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 From b52b898c677415b3882b91f3d9c85f283b96893a Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:18:48 -0300 Subject: [PATCH 7/7] feat(daemon): add the serve command `botloftd serve [--config ]` resolves the data folder, loads the config, starts daily-rotated logs (14 kept), takes the instance lock, prepares the owner token and database and listens on 127.0.0.1 until Ctrl+C or a Windows close/logoff/shutdown signal. log_level applies to Botloft's crates only; dependencies stay at warn because the WebSocket stack logs frames at debug and trace. --- crates/botloftd/src/lib.rs | 1 + crates/botloftd/src/logging.rs | 39 ++++++++++++++ crates/botloftd/src/main.rs | 93 +++++++++++++++++++++++++++++++--- 3 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 crates/botloftd/src/logging.rs diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index 527fa32b..adf7b864 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -3,6 +3,7 @@ //! parses the command line and wires these modules together. pub mod config; +pub mod logging; pub mod paths; pub mod platform; pub mod rpc; 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..b2e76b14 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -1,9 +1,86 @@ -//! `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::state::{Daemon, DaemonOptions}; +use botloftd::{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, + }, +} + +fn main() -> anyhow::Result<()> { + match Cli::parse().command { + Command::Serve { config } => serve(config), + } +} + +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 = Arc::new(Daemon::new(DaemonOptions { + paths, + port: config.port, + bin, + store, + owner_token, + })); + + 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"); + server::serve(daemon, listener, platform::shutdown_signal()).await?; + info!("stopped"); + anyhow::Ok(()) + }) }