From 7205b6b8c383c36d2d5aa64e20d4886288b76080 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:16:46 -0300 Subject: [PATCH 1/3] feat(daemon): botloftd.exe says what it is and which version The daemon's executable had no version information at all. It now carries the product name "Botloft", the description "Botloft background service", the crate's version and the copyright, from a VERSIONINFO block in botloftd.rc that build.rs fills in. Windows shows it in the file's properties and in Task Manager, a code signing service checks it against the product it signs, and a binary with no metadata is one of the things antivirus heuristics distrust. --- crates/botloftd/botloftd.rc | 32 +++++++++++++++++++++++++-- crates/botloftd/build.rs | 44 ++++++++++++++++++++++++++++++------- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/crates/botloftd/botloftd.rc b/crates/botloftd/botloftd.rc index fac54ca2..03081418 100644 --- a/crates/botloftd/botloftd.rc +++ b/crates/botloftd/botloftd.rc @@ -1,3 +1,31 @@ -// The daemon's Windows resources: only its manifest (spec 14). +// The daemon's Windows resources (spec 14): its manifest and its version +// information. build.rs fills in the @PLACEHOLDERS@ and compiles the result. #define RT_MANIFEST 24 -1 RT_MANIFEST "botloftd.manifest" +1 RT_MANIFEST "@MANIFEST@" + +// What Windows shows in the file's properties and in Task Manager, and what a +// code signing service checks against the product it signs (spec 15.5). +1 VERSIONINFO +FILEVERSION @VERSION_NUMBERS@ +PRODUCTVERSION @VERSION_NUMBERS@ +FILEOS 0x40004L +FILETYPE 0x1L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "ProductName", "Botloft" + VALUE "FileDescription", "Botloft background service" + VALUE "FileVersion", "@VERSION@" + VALUE "ProductVersion", "@VERSION@" + VALUE "LegalCopyright", "Copyright 2026 Phelipe Lorran" + VALUE "InternalName", "botloftd" + VALUE "OriginalFilename", "botloftd.exe" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/crates/botloftd/build.rs b/crates/botloftd/build.rs index 9c3a71d0..9c7f8d7b 100644 --- a/crates/botloftd/build.rs +++ b/crates/botloftd/build.rs @@ -1,14 +1,42 @@ -//! Embeds `botloftd.manifest` in the Windows binary (spec 14). It goes in -//! as a resource: the linker's own manifest tool predates -//! `consoleAllocationPolicy` and warns about it on every build. +//! Embeds the daemon's Windows resources (spec 14): `botloftd.manifest` and +//! the version information. The manifest goes in as a resource because the +//! linker's own manifest tool predates `consoleAllocationPolicy` and warns +//! about it on every build. + +use std::path::PathBuf; +use std::{env, fs}; + +/// `1.2.3` (with or without a tag) as the four numbers Windows wants. +fn version_numbers(version: &str) -> String { + let core = version.split(['-', '+']).next().unwrap_or_default(); + let mut parts: Vec<&str> = core.split('.').collect(); + parts.resize(4, "0"); + parts.join(",") +} fn main() { println!("cargo:rerun-if-changed=botloftd.rc"); println!("cargo:rerun-if-changed=botloftd.manifest"); - if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") - && let Err(err) = - embed_resource::compile("botloftd.rc", embed_resource::NONE).manifest_required() - { - panic!("cannot embed the manifest: {err}"); + if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let here = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets the crate dir")); + let version = env::var("CARGO_PKG_VERSION").expect("cargo sets the version"); + // Forward slashes: a backslash starts an escape in a resource script. + let manifest = here + .join("botloftd.manifest") + .display() + .to_string() + .replace('\\', "/"); + let script = fs::read_to_string(here.join("botloftd.rc")) + .expect("cannot read botloftd.rc") + .replace("@MANIFEST@", &manifest) + .replace("@VERSION_NUMBERS@", &version_numbers(&version)) + .replace("@VERSION@", &version); + let out = + PathBuf::from(env::var_os("OUT_DIR").expect("cargo sets OUT_DIR")).join("botloftd.rc"); + fs::write(&out, script).expect("cannot write the resource script"); + if let Err(err) = embed_resource::compile(&out, embed_resource::NONE).manifest_required() { + panic!("cannot embed the resources: {err}"); } } From 3f687856861922445e96c977706c17b01323b979 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:16:46 -0300 Subject: [PATCH 2/3] feat(release): code signing wired through one script, off until there is a certificate scripts/sign.mjs signs one binary with signtool and the certificate picked by BOTLOFT_SIGN_THUMBPRINT from the Windows certificate store, timestamps it and checks the result. Without the variable it does nothing, so dev builds and releases made before the certificate exists stay unsigned; with it and no certificate it fails, so a release never goes out unsigned by mistake. Tauri runs it (bundle.windows.signCommand) for everything that goes into the installer: the app, the daemon, the NSIS plugins, the uninstaller and the installer itself. setup.mjs runs it for the setup window. release.yml passes the repository variables. The updater's signature and the build attestation are made afterwards, over the signed files. Spec 15.5 describes it, the certificate options looked at for an individual in Brazil, and the Defender false positive on 0.6.0 that made signing necessary; 15.7 records the real update from 0.5.0 to 0.6.0. --- .github/workflows/release.yml | 7 +++ CLAUDE.md | 2 +- app/scripts/setup.mjs | 6 +++ app/scripts/sign.mjs | 76 ++++++++++++++++++++++++++++ app/src-tauri/tauri.bundle.conf.json | 1 + docs/spec.md | 6 ++- 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 app/scripts/sign.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 105082eb..7cec78a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,13 @@ permissions: env: CARGO_TERM_COLOR: always + # Code signing (spec 15.5). With the certificate's thumbprint in the + # repository variable BOTLOFT_SIGN_THUMBPRINT, scripts/sign.mjs signs every + # binary; without it the release goes out unsigned, as today. The step that + # puts the certificate in the runner's certificate store (signing in to the + # cloud key's client) goes before "Build the signed installer". + BOTLOFT_SIGN_THUMBPRINT: ${{ vars.BOTLOFT_SIGN_THUMBPRINT }} + BOTLOFT_SIGN_TIMESTAMP: ${{ vars.BOTLOFT_SIGN_TIMESTAMP }} jobs: installer: diff --git a/CLAUDE.md b/CLAUDE.md index c6f20746..9cb7c8a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release pnpm bundle:setup # tela de instalação com esse NSIS dentro (spec 15.7), em target\release\bundle\setup ``` -Release (spec 15.5): suba a versão em `[workspace.package]` do `Cargo.toml` (e no `app/package.json`), faça merge, crie a tag `vX.Y.Z` e dê push. O workflow `release.yml` abre um release rascunho com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) com o `.sig` e o `latest.json`; o updater só o vê depois de publicado. Os dois `.exe` ganham um atestado de origem (`gh attestation verify`), e as notas do rascunho começam com os SHA-256. `node app/scripts/release.mjs stage` monta esses arquivos localmente para conferir. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou. +Release (spec 15.5): suba a versão em `[workspace.package]` do `Cargo.toml` (e no `app/package.json`), faça merge, crie a tag `vX.Y.Z` e dê push. O workflow `release.yml` abre um release rascunho com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) com o `.sig` e o `latest.json`; o updater só o vê depois de publicado. Os dois `.exe` ganham um atestado de origem (`gh attestation verify`), e as notas do rascunho começam com os SHA-256. `node app/scripts/release.mjs stage` monta esses arquivos localmente para conferir. A assinatura de código passa por `app/scripts/sign.mjs` e só liga com a variável `BOTLOFT_SIGN_THUMBPRINT` (spec 15.5); a política pública fica em `docs/code-signing-policy.md`. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou. Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon e no app, para não tocar na instalação real em `%LOCALAPPDATA%\Botloft`. O `config.toml` de dev fica dentro dessa pasta: o app lê a porta de lá. diff --git a/app/scripts/setup.mjs b/app/scripts/setup.mjs index aa94e19e..f5a777bd 100644 --- a/app/scripts/setup.mjs +++ b/app/scripts/setup.mjs @@ -40,6 +40,12 @@ execFileSync( { cwd: root, stdio: "inherit", env }, ); +execFileSync( + process.execPath, + [join(app, "scripts", "sign.mjs"), join(target, "release", "botloft-setup.exe")], + { stdio: "inherit" }, +); + const out = join(target, "release", "bundle", "setup"); mkdirSync(out, { recursive: true }); const setup = join(out, `Botloft_${version}_x64-setup.exe`); diff --git a/app/scripts/sign.mjs b/app/scripts/sign.mjs new file mode 100644 index 00000000..e6543b4e --- /dev/null +++ b/app/scripts/sign.mjs @@ -0,0 +1,76 @@ +// Signs one Windows binary with the release certificate (spec 15.5). +// +// node scripts/sign.mjs +// +// Tauri runs it for everything that goes into the installer +// (`bundle.windows.signCommand`): the app, the daemon, the NSIS plugins, the +// uninstaller and the installer itself. setup.mjs runs it for the setup +// window, which Tauri does not build. The certificate lives in the Windows +// certificate store of the machine that builds (a cloud key shows up there +// once its client is signed in), and is picked by its SHA-1 thumbprint: +// +// BOTLOFT_SIGN_THUMBPRINT the certificate's thumbprint; without it nothing +// is signed, as in dev builds and in releases +// made before the certificate exists +// BOTLOFT_SIGN_TIMESTAMP RFC 3161 timestamp server (a public one by default) +// BOTLOFT_SIGNTOOL signtool.exe, when it is not in the Windows SDK + +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { basename, join } from "node:path"; + +const file = process.argv[2]; +if (!file) { + throw new Error("usage: sign.mjs "); +} +const thumbprint = (process.env.BOTLOFT_SIGN_THUMBPRINT ?? "").replace(/\s/g, ""); +if (!thumbprint) { + console.log(`not signing ${basename(file)}: BOTLOFT_SIGN_THUMBPRINT is not set`); + process.exit(0); +} + +/** signtool.exe from the newest Windows SDK on this machine. */ +function signtool() { + if (process.env.BOTLOFT_SIGNTOOL) { + return process.env.BOTLOFT_SIGNTOOL; + } + const kits = join(process.env["ProgramFiles(x86)"] ?? "", "Windows Kits", "10", "bin"); + const versions = existsSync(kits) + ? readdirSync(kits) + .filter((name) => /^10\./.test(name)) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + : []; + for (const version of versions) { + const tool = join(kits, version, "x64", "signtool.exe"); + if (existsSync(tool)) { + return tool; + } + } + throw new Error(`signtool.exe not found under ${kits}; set BOTLOFT_SIGNTOOL`); +} + +const tool = signtool(); +const timestamp = process.env.BOTLOFT_SIGN_TIMESTAMP || "http://timestamp.digicert.com"; +execFileSync( + tool, + [ + "sign", + "/sha1", + thumbprint, + "/fd", + "sha256", + "/tr", + timestamp, + "/td", + "sha256", + "/d", + "Botloft", + "/du", + "https://github.com/httpsphl/botloft", + file, + ], + { stdio: "inherit" }, +); +// A signature Windows does not trust would only show up on the owner's PC. +execFileSync(tool, ["verify", "/pa", file], { stdio: "inherit" }); +console.log(`signed ${basename(file)}`); diff --git a/app/src-tauri/tauri.bundle.conf.json b/app/src-tauri/tauri.bundle.conf.json index fc42e1dc..e6bb535e 100644 --- a/app/src-tauri/tauri.bundle.conf.json +++ b/app/src-tauri/tauri.bundle.conf.json @@ -4,6 +4,7 @@ "externalBin": ["binaries/botloftd"], "createUpdaterArtifacts": true, "windows": { + "signCommand": { "cmd": "node", "args": ["../scripts/sign.mjs", "%1"] }, "nsis": { "installMode": "currentUser", "installerIcon": "icons/icon.ico", diff --git a/docs/spec.md b/docs/spec.md index 1be68ac7..062638e2 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -718,6 +718,10 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a - Os artefatos são assinados com a chave do updater do Tauri (`createUpdaterArtifacts`), e a chave pública fica no `tauri.conf.json`. `requireSignedVersion` exige que a assinatura traga a versão, para um feed adulterado não empurrar uma versão antiga de volta. O instalador não tem assinatura Authenticode, então o SmartScreen avisa na primeira execução. - Release: a versão fica só no `[workspace.package]` do `Cargo.toml` (o `tauri.conf.json` não repete a versão e usa a do crate). Um push de tag `vX.Y.Z` roda `.github/workflows/release.yml`, que confere tag e versão, roda `pnpm bundle` com `TAURI_SIGNING_PRIVATE_KEY` (segredo do repositório), depois `pnpm bundle:setup` (15.7), `release.mjs stage`, o atestado de origem e `release.mjs publish`, que abre um release **rascunho** com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) e o `.sig` dele, e o `latest.json`. O updater só enxerga o release depois que o dono o publica. - Atestado de origem: entre o `stage` e o `publish`, o workflow roda `actions/attest@v4` nos dois `.exe` (permissões `id-token`, `attestations` e `artifact-metadata`). É um atestado de proveniência SLSA assinado pelo Sigstore: qualquer pessoa confere com `gh attestation verify --repo httpsphl/botloft` que o instalador saiu deste workflow, deste repositório e daquele commit. As notas do rascunho começam com "Verify your download": o SHA-256 dos dois instaladores e esse comando. O VirusTotal fica de fora das notas enquanto a tela de instalação tiver alertas de aprendizado de máquina (na 0.6.0, 2 de 71: Microsoft `Wacatac.C!ml` e Trapmine), à espera da análise de falso positivo. +- Assinatura de código (Authenticode): ainda não há certificado, e sem ele o release sai sem assinatura. O caminho já está pronto: `app/scripts/sign.mjs ` assina com o `signtool` do Windows SDK o certificado que estiver no repositório de certificados da máquina, escolhido pela impressão digital em `BOTLOFT_SIGN_THUMBPRINT` (uma chave em nuvem aparece ali depois que o cliente dela entra na conta), com carimbo de tempo RFC 3161 (`BOTLOFT_SIGN_TIMESTAMP`) e confere a assinatura em seguida. Sem a variável, não faz nada; com ela e sem o certificado, falha, para um release nunca sair sem assinatura por engano. Quem chama: o Tauri, pelo `bundle.windows.signCommand` do `tauri.bundle.conf.json`, para tudo que entra no instalador (o `Botloft.exe`, o sidecar `botloftd.exe`, os plugins do NSIS, o desinstalador e o próprio instalador; ele pula o que já estiver assinado); e o `setup.mjs` para a tela de instalação, que o Tauri não monta. A cópia do daemon em `\bin` é o mesmo arquivo assinado do instalador. O `release.yml` passa as duas variáveis do repositório. O `.sig` do atualizador e o atestado de origem são feitos depois, sobre os arquivos já assinados. +- Todo binário leva nome do produto e versão nos metadados, que é o que um serviço de assinatura confere: o app e a tela de instalação pelo `tauri-build`, o instalador pelo NSIS e o `botloftd.exe` por um `VERSIONINFO` no `botloftd.rc` (produto "Botloft", descrição "Botloft background service", versão do crate), preenchido pelo `build.rs`. +- Opções de certificado vistas em 2026-09-30, para uma pessoa física no Brasil: o Azure Artifact Signing não atende (pessoa física só nos EUA e no Canadá); a SignPath Foundation assina de graça projetos abertos aprovados, com o nome dela como editor e uma aprovação manual por release; o certificado Open Source da Certum (a partir de € 49, chave em nuvem) sai no nome do desenvolvedor; a SSL.com tem validação individual com assinatura em nuvem e action oficial, por cerca de US$ 300 por ano. Nenhuma tira o aviso do SmartScreen de imediato: a reputação cresce a cada release assinado com a mesma identidade. A política pública está em `docs/code-signing-policy.md`. +- Falso positivo do Defender (0.6.0, 2026-09-30): no PC do dono, o Microsoft Defender pôs em quarentena, por detecção de nuvem, o `-setup.exe` baixado (`Trojan:Win32/Wacatac.C!ml`) e depois o `Botloft.exe` instalado e em uso (`Trojan:Script/Wacatac.C!ml`), apagando os atalhos; o daemon não foi tocado e os bots seguiram. São palpites de aprendizado de máquina sobre binários novos e sem assinatura. Os dois arquivos foram enviados à Microsoft como falso positivo. É o motivo de a assinatura ter deixado de ser opcional para distribuir o app. ### 15.6 Idiomas @@ -768,7 +772,7 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a - `/S` por cima de uma versão instalada: se desinstala a anterior (como o `/P` faz pela página de reinstalação) ou só sobrescreve, e se algum arquivo antigo fica. **Mesma versão** (0.5.0 sobre 0.5.0, 2026-09-29): nenhum processo de desinstalador apareceu (olhando a cada 100 ms); o NSIS só sobrescreveu os arquivos. Falta ver com uma versão mais antiga (I3). - `/S` com o app aberto: se o NSIS fecha o app sozinho, pergunta ou falha. O setup fecha o app antes (verificado: "Instalar de novo" com o app aberto fechou o app, instalou e abriu de novo), mas o comportamento do NSIS sozinho precisa ficar anotado. - `/S` com uma versão mais nova instalada: se sai com erro (`silentDowngrades`) e com qual código. -- O atualizador aceita o instalador com o nome novo (`-update.exe`) e o roda como NSIS. **Na fonte** (`tauri-plugin-updater` 2.13, `extract_exe`): o tipo vem dos bytes baixados, e qualquer `.exe` é tratado como NSIS; o nome não conta. Falta ver a atualização de verdade da 0.5.0 para a 0.6.0. +- O atualizador aceita o instalador com o nome novo (`-update.exe`) e o roda como NSIS. **Na fonte** (`tauri-plugin-updater` 2.13, `extract_exe`): o tipo vem dos bytes baixados, e qualquer `.exe` é tratado como NSIS; o nome não conta. **Verificado** (2026-09-30): o app instalado na 0.5.0 se atualizou para a 0.6.0 pelo `-update.exe`, com a janela passiva do NSIS, e a entrada em Aplicativos instalados e a pasta ficaram as mesmas. - Com o manifesto `asInvoker`, o Windows não pede administrador para o setup. **Verificado** (Windows 11 25H2, 2026-09-29): o `Botloft_0.5.0_x64-setup.exe` abriu a janela direto, sem pedido do UAC. - O identificador dos atalhos continua o mesmo, para os avisos saírem com o nome do Botloft. From e7865cdce2c078a9408e667351440beb114ec6b5 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:16:46 -0300 Subject: [PATCH 3/3] docs: a code signing policy, with privacy and what Botloft changes on the computer Says that releases are not signed yet and what that means, how releases are made and approved, who may release, what goes over the network (the update check and the bots' own Claude Code), what Botloft changes on the computer (per-user install, the scheduled task, the startup entry, keep awake) and how to remove it. The README links to it from the privacy section and from the FAQ. --- README.md | 6 +++- docs/code-signing-policy.md | 71 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 docs/code-signing-policy.md diff --git a/README.md b/README.md index 7d1c8a96..33b9dc45 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,9 @@ The full design, from the protocol to the states of a bot, lives in [`docs/spec. cooperative: a bot can read other bots' folders if it decides to. Give bots only the permissions you would give Claude Code directly. +What Botloft sends over the network, what it changes on your computer and how to remove it are in +the [code signing policy](docs/code-signing-policy.md). + ## FAQ
@@ -150,7 +153,8 @@ that you can point at a project of yours. Botloft's own data (SQLite, logs, secr The installer is not code-signed yet, so SmartScreen does not know its publisher. Each release is built by GitHub Actions from this repository, and updates are signed and checked by the app before -they install. +they install. The [code signing policy](docs/code-signing-policy.md) says how releases are made and +how to check a download.
diff --git a/docs/code-signing-policy.md b/docs/code-signing-policy.md new file mode 100644 index 00000000..6e7b3666 --- /dev/null +++ b/docs/code-signing-policy.md @@ -0,0 +1,71 @@ +# Code signing policy + +This page says how Botloft's releases are made, who may release them, what Botloft sends over the +network and what it changes on your computer. + +## Status + +Botloft's releases are **not code-signed yet**. Until they are, Windows SmartScreen may ask you to +confirm when you run the installer, and an antivirus may flag it by guesswork (a "machine-learning" +or "heuristic" detection), as happens with new programs that have no signature. If that happens to +you, please [open an issue](https://github.com/httpsphl/botloft/issues); we report every false +positive to the vendor. + +The release pipeline is ready to sign `Botloft.exe`, `botloftd.exe`, the setup window, the installer +and the uninstaller as soon as a certificate is in place. All of them carry the product name +"Botloft" and the version of the release. + +## How releases are made + +- Every release is built by GitHub Actions ([`release.yml`](../.github/workflows/release.yml)) from + a `vX.Y.Z` tag on `main`, on GitHub's own runners. Nothing is built on a maintainer's computer. +- The workflow opens a draft release. A person reads it and publishes it: no release goes out + without that manual approval. +- Each installer gets a build provenance attestation, and the release notes list its SHA-256. To + check that a download came from this repository's workflow, with the + [GitHub CLI](https://cli.github.com/): + + ``` + gh attestation verify Botloft__x64-setup.exe --repo httpsphl/botloft + ``` + + This applies to releases after 0.6.0. +- Updates are signed with Botloft's own updater key. The app checks the signature, and that it was + made for the version announced, before it installs anything. + +## Team roles + +- **Committers and reviewers:** [Phelipe Lorran](https://github.com/httpsphl) +- **Approvers:** [Phelipe Lorran](https://github.com/httpsphl) + +## Privacy + +Botloft has no telemetry, no analytics and no accounts of its own. The background service listens +on `127.0.0.1` only. What goes over the network: + +- **Update check.** When the app opens, and every 6 hours while it is open, it asks GitHub for the + latest release's `latest.json`. It downloads an update only when you click **Update now**. No + information about you or your bots is sent. +- **Your bots.** Each bot is Claude Code running on your computer with your own Claude account. What + it sends to Anthropic, and the websites it opens when its work needs them, are the same as when + you use Claude Code yourself. + +## What Botloft changes on your computer + +- It installs for your Windows user only, under `%LOCALAPPDATA%\Botloft`, with shortcuts in the + Start menu and on the desktop. It never asks for an administrator. +- It registers a scheduled task for your user, named "Botloft", that keeps the bots running: it + starts when you sign in and brings the background service back if it stops. Settings > General + turns off starting with Windows, or stops the bots when you close the app. +- While the bots start with Windows and Botloft stays near the clock or opens its window at sign-in, + the app adds itself to your user's startup programs. Settings > General turns each of these off. +- While a bot is working, it asks Windows not to put the computer to sleep. Settings > General turns + this off. +- Bots keep their work in `%USERPROFILE%\Botloft`, or in the folder you pick for a crew. + +## Uninstalling + +Settings > Apps > Installed apps > Botloft > Uninstall removes the app, stops the background +service, and removes its scheduled task and the startup entry. Your bots, their conversations and +their files stay in `%LOCALAPPDATA%\Botloft` and `%USERPROFILE%\Botloft`; delete those two folders +to remove everything.