Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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á.

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<details>
Expand Down Expand Up @@ -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.
</details>

<details>
Expand Down
6 changes: 6 additions & 0 deletions app/scripts/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
76 changes: 76 additions & 0 deletions app/scripts/sign.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Signs one Windows binary with the release certificate (spec 15.5).
//
// node scripts/sign.mjs <file>
//
// 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 <file>");
}
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)}`);
1 change: 1 addition & 0 deletions app/src-tauri/tauri.bundle.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 30 additions & 2 deletions crates/botloftd/botloftd.rc
Original file line number Diff line number Diff line change
@@ -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
44 changes: 36 additions & 8 deletions crates/botloftd/build.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
}
71 changes: 71 additions & 0 deletions docs/code-signing-policy.md
Original file line number Diff line number Diff line change
@@ -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_<version>_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.
Loading
Loading