+
{a.title}
+ {!status.available ? (
+
{a.unavailable}
+ ) : status.enabled ? (
+ <>
+
+ {status.every === "weekly" ? a.onWeekly : a.onDaily}
+
+
+ {status.lastOkAt !== undefined ? a.lastCopy(when(status.lastOkAt)) : a.noneYet}
+ {status.nextAt !== undefined && ` ${a.next(when(status.nextAt))}`}
+
+ {status.lastError && (
+
+ {t.reasons[status.lastError] ?? t.reasons.cloud_error}
+
+ )}
+
+
+
+ {a.turnOff}
+
+
+ >
+ ) : (
+ <>
+
{a.intro}
+
+
{a.tip}
+
+
+ label={a.every}
+ value={every}
+ options={options}
+ onChange={setEvery}
+ />
+
+ {busy ? a.turningOn : a.turnOn}
+
+
+ >
+ )}
+ {problem && (
+
+ {problem}
+
+ )}
+
+ );
+}
diff --git a/app/src/features/account/CloudSignedIn.tsx b/app/src/features/account/CloudSignedIn.tsx
index b68dc626..7c53ad52 100644
--- a/app/src/features/account/CloudSignedIn.tsx
+++ b/app/src/features/account/CloudSignedIn.tsx
@@ -10,6 +10,7 @@ import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { Confirm } from "../../ui/Confirm";
import { backupError } from "./backupError";
+import { CloudAuto } from "./CloudAuto";
import { CloudCopies } from "./CloudCopies";
import { CloudUpload } from "./CloudUpload";
@@ -64,6 +65,7 @@ export function CloudSignedIn({
void refresh();
}}
/>
+
diff --git a/app/src/features/account/autoBackup.test.tsx b/app/src/features/account/autoBackup.test.tsx
new file mode 100644
index 00000000..8c0f10e5
--- /dev/null
+++ b/app/src/features/account/autoBackup.test.tsx
@@ -0,0 +1,133 @@
+import { act, cleanup, configure, fireEvent, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { FakeBotloft } from "../../lib/fake";
+import { fakeAutoBackupSent, setFakeAutoBackup } from "../../lib/fakeAutoBackup";
+import { openFakeCloudLink } from "../../lib/fakeCloud";
+import { FakeHost } from "../../lib/fakeHost";
+import { crewOpened, renderApp } from "../../test/app";
+
+afterEach(cleanup);
+
+// Each test signs in and opens menus: a loaded machine needs more than a second.
+configure({ asyncUtilTimeout: 5000 });
+vi.setConfig({ testTimeout: 20_000 });
+
+const type = (field: HTMLElement, value: string) => fireEvent.change(field, { target: { value } });
+
+/** Settings, Backup, signed in to the account. */
+async function signedIn() {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ fake.addBot(ops.id, "Scout");
+ renderApp(fake, new FakeHost());
+ await crewOpened("Ops");
+ fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ }));
+ fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
+ const dialog = screen.getByRole("dialog", { name: "Settings" });
+ fireEvent.click(within(dialog).getByRole("tab", { name: "Backup" }));
+ type(await within(dialog).findByLabelText("Your e-mail"), "ana@exemplo.com");
+ fireEvent.click(within(dialog).getByRole("button", { name: "Send me the link" }));
+ await within(dialog).findByText(/We sent a link/);
+ act(() => openFakeCloudLink(fake));
+ await within(dialog).findByText("Signed in as ana@exemplo.com");
+ // The block asks the daemon for its status, so it is there a moment later.
+ await within(dialog).findByText("Automatic copies");
+ return { fake, dialog };
+}
+
+/** The passphrase fields of the automatic block: the second pair, after the upload's. */
+function typePassphrase(dialog: HTMLElement, value: string) {
+ const first = within(dialog).getAllByLabelText("Passphrase")[1] as HTMLElement;
+ const again = within(dialog).getAllByLabelText("Passphrase again")[1] as HTMLElement;
+ type(first, value);
+ type(again, value);
+}
+
+const calls = (fake: FakeBotloft, method: string) =>
+ fake.calls.filter((call) => call.method === method);
+
+describe("automatic copies", () => {
+ test("it is off until the owner turns it on with a passphrase and how often", async () => {
+ const { fake, dialog } = await signedIn();
+ expect(await within(dialog).findByText("Automatic copies")).toBeDefined();
+ const turnOn = within(dialog).getByRole("button", { name: "Turn on" });
+ expect(turnOn).toHaveProperty("disabled", true);
+
+ typePassphrase(dialog, "correct horse");
+ expect(turnOn).toHaveProperty("disabled", false);
+ fireEvent.click(within(dialog).getByRole("button", { name: "How often: Every day" }));
+ fireEvent.click(await screen.findByRole("option", { name: "Every week" }));
+ fireEvent.click(turnOn);
+
+ expect(
+ await within(dialog).findByText("On: a copy every week, if something changed."),
+ ).toBeDefined();
+ expect(calls(fake, "autobackup.enable").at(-1)?.params).toEqual({
+ passphrase: "correct horse",
+ every: "weekly",
+ });
+ // The passphrase is not kept on the screen once it is on.
+ expect(within(dialog).queryByRole("button", { name: "Turn on" })).toBeNull();
+ });
+
+ test("it shows the last copy, the next look and changes how often", async () => {
+ const { fake, dialog } = await signedIn();
+ typePassphrase(dialog, "correct horse");
+ fireEvent.click(within(dialog).getByRole("button", { name: "Turn on" }));
+ await within(dialog).findByText(/On: a copy every day/);
+ expect(within(dialog).getByText(/No copy sent yet\./)).toBeDefined();
+
+ act(() => fakeAutoBackupSent(fake));
+ expect(await within(dialog).findByText(/Last copy sent:/)).toBeDefined();
+ expect(within(dialog).getByText(/Next look:/)).toBeDefined();
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "How often: Every day" }));
+ fireEvent.click(await screen.findByRole("option", { name: "Every week" }));
+ await within(dialog).findByText(/On: a copy every week/);
+ expect(calls(fake, "autobackup.set_every").at(-1)?.params).toEqual({ every: "weekly" });
+ });
+
+ test("a failed try says why, in words", async () => {
+ const { fake, dialog } = await signedIn();
+ typePassphrase(dialog, "correct horse");
+ fireEvent.click(within(dialog).getByRole("button", { name: "Turn on" }));
+ await within(dialog).findByText(/On: a copy every day/);
+
+ act(() => setFakeAutoBackup(fake, { lastError: "offline" }));
+ expect(await within(dialog).findByText("The last try failed")).toBeDefined();
+ expect(
+ within(dialog).getByText("Could not reach the cloud. Check your connection and try again."),
+ ).toBeDefined();
+ });
+
+ test("turning it off brings back the passphrase fields", async () => {
+ const { fake, dialog } = await signedIn();
+ typePassphrase(dialog, "correct horse");
+ fireEvent.click(within(dialog).getByRole("button", { name: "Turn on" }));
+ await within(dialog).findByText(/On: a copy every day/);
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "Turn off" }));
+ expect(await within(dialog).findByRole("button", { name: "Turn on" })).toBeDefined();
+ expect(calls(fake, "autobackup.disable")).toHaveLength(1);
+ });
+
+ test("where the computer has no credential store it says so and offers nothing", async () => {
+ const { fake, dialog } = await signedIn();
+ act(() => setFakeAutoBackup(fake, { available: false }));
+ expect(
+ await within(dialog).findByText("Automatic copies work only on Windows for now."),
+ ).toBeDefined();
+ expect(within(dialog).queryByRole("button", { name: "Turn on" })).toBeNull();
+ });
+
+ test("a short passphrase keeps the button off", async () => {
+ const { fake, dialog } = await signedIn();
+ // The screen already holds back a short one; the fake refuses it too.
+ typePassphrase(dialog, "short");
+ expect(within(dialog).getByRole("button", { name: "Turn on" })).toHaveProperty(
+ "disabled",
+ true,
+ );
+ expect(calls(fake, "autobackup.enable")).toHaveLength(0);
+ });
+});
diff --git a/app/src/features/account/cloud.test.tsx b/app/src/features/account/cloud.test.tsx
index 6890e40d..047701dd 100644
--- a/app/src/features/account/cloud.test.tsx
+++ b/app/src/features/account/cloud.test.tsx
@@ -94,7 +94,7 @@ describe("the account and the copies in the cloud", () => {
sendCopy(dialog);
await within(dialog).findByText("Copy sent (47 KB).");
- fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
+ fireEvent.click(await within(dialog).findByRole("button", { name: "Delete" }));
const ask = await screen.findByRole("dialog", { name: "Delete this copy?" });
fireEvent.click(within(ask).getByRole("button", { name: "Cancel" }));
await waitFor(() =>
@@ -102,7 +102,7 @@ describe("the account and the copies in the cloud", () => {
);
expect(fake.calls.some((each) => each.method === "cloud.delete")).toBe(false);
- fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
+ fireEvent.click(await within(dialog).findByRole("button", { name: "Delete" }));
const again = await screen.findByRole("dialog", { name: "Delete this copy?" });
fireEvent.click(within(again).getByRole("button", { name: "Delete" }));
expect(await within(dialog).findByText("No copies yet.")).toBeDefined();
diff --git a/app/src/features/account/useAutoBackup.ts b/app/src/features/account/useAutoBackup.ts
new file mode 100644
index 00000000..e4fa5a60
--- /dev/null
+++ b/app/src/features/account/useAutoBackup.ts
@@ -0,0 +1,30 @@
+// What the account block knows of the automatic backup (spec 27.10): its
+// status, kept current by the daemon's `autobackup.changed`.
+
+import { useCallback, useEffect, useState } from "react";
+import type { AutoBackupStatus } from "../../lib/protocol.gen";
+import { useApi } from "../../store/context";
+
+export function useAutoBackup() {
+ const api = useApi();
+ const [status, setStatus] = useState
(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ setStatus(await api.call("autobackup.status"));
+ } catch {
+ // The screen keeps what it had.
+ }
+ }, [api]);
+
+ useEffect(() => {
+ void refresh();
+ return api.subscribe((event) => {
+ if (event.name === "autobackup.changed") {
+ setStatus(event.params);
+ }
+ });
+ }, [api, refresh]);
+
+ return { status, setStatus };
+}
diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts
index 878d0b46..5d8e67f3 100644
--- a/app/src/i18n/en/account.ts
+++ b/app/src/i18n/en/account.ts
@@ -135,6 +135,26 @@ export const account = {
"This deletes your account and every copy saved in it, and it cannot be undone. We send a link to your e-mail, and nothing is deleted until you press the button there.",
removeAccountSend: "Send the link",
removeAccountSent: "We sent a link to your e-mail. Open it and press the button to finish.",
+ auto: {
+ title: "Automatic copies",
+ intro:
+ "Botloft can send a copy to the cloud by itself, and only when something changed. Your passphrase is kept in Windows' credential store, on this computer only.",
+ tip: "Use a passphrase just for this, one you do not use anywhere else.",
+ every: "How often",
+ daily: "Every day",
+ weekly: "Every week",
+ turnOn: "Turn on",
+ turningOn: "Turning on…",
+ turnOff: "Turn off",
+ onDaily: "On: a copy every day, if something changed.",
+ onWeekly: "On: a copy every week, if something changed.",
+ lastCopy: (when: string) => `Last copy sent: ${when}.`,
+ noneYet: "No copy sent yet.",
+ next: (when: string) => `Next look: ${when}.`,
+ unavailable: "Automatic copies work only on Windows for now.",
+ failed: "Could not turn it on",
+ lastFailed: "The last try failed",
+ },
failed: "Something went wrong",
reasons: {
no_server: "This Botloft has no cloud set up yet.",
@@ -150,6 +170,9 @@ export const account = {
not_found: "That copy is no longer there.",
bad_hash: "The copy was damaged on the way. Try again.",
cloud_error: "Something went wrong with the cloud. Try again in a moment.",
+ no_keystore: "This computer has nowhere safe to keep the passphrase.",
+ no_passphrase:
+ "The passphrase is gone from this computer, so automatic copies stopped. Turn them on again.",
} as Record,
},
settings: {
diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts
index 87aebea5..af957206 100644
--- a/app/src/i18n/es/account.ts
+++ b/app/src/i18n/es/account.ts
@@ -137,6 +137,26 @@ export const account: Messages["account"] = {
"Esto borra tu cuenta y todas las copias guardadas en ella, y no se puede deshacer. Enviamos un enlace a tu correo, y no se borra nada hasta que pulses el botón de allí.",
removeAccountSend: "Enviar el enlace",
removeAccountSent: "Enviamos un enlace a tu correo. Ábrelo y pulsa el botón para terminar.",
+ auto: {
+ title: "Copias automáticas",
+ intro:
+ "Botloft puede enviar una copia a la nube por sí solo, y solo cuando algo cambió. La contraseña queda guardada en el almacén de credenciales de Windows, solo en este equipo.",
+ tip: "Usa una contraseña solo para esto, que no uses en ningún otro lugar.",
+ every: "Con qué frecuencia",
+ daily: "Cada día",
+ weekly: "Cada semana",
+ turnOn: "Activar",
+ turningOn: "Activando…",
+ turnOff: "Desactivar",
+ onDaily: "Activado: una copia al día, si algo cambió.",
+ onWeekly: "Activado: una copia a la semana, si algo cambió.",
+ lastCopy: (when: string) => `Última copia enviada: ${when}.`,
+ noneYet: "Aún no se envió ninguna copia.",
+ next: (when: string) => `Próxima revisión: ${when}.`,
+ unavailable: "Las copias automáticas solo funcionan en Windows por ahora.",
+ failed: "No se pudo activar",
+ lastFailed: "El último intento falló",
+ },
failed: "Algo salió mal",
reasons: {
no_server: "Este Botloft aún no tiene la nube configurada.",
@@ -152,6 +172,9 @@ export const account: Messages["account"] = {
not_found: "Esa copia ya no está.",
bad_hash: "La copia se dañó por el camino. Inténtalo de nuevo.",
cloud_error: "Algo salió mal con la nube. Inténtalo de nuevo en un momento.",
+ no_keystore: "Este equipo no tiene dónde guardar la contraseña de forma segura.",
+ no_passphrase:
+ "La contraseña ya no está en este equipo, así que las copias automáticas se detuvieron. Actívalas de nuevo.",
} as Record,
},
settings: {
diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts
index 474e233b..66c01026 100644
--- a/app/src/i18n/pt-BR/account.ts
+++ b/app/src/i18n/pt-BR/account.ts
@@ -137,6 +137,26 @@ export const account: Messages["account"] = {
"Isto apaga a sua conta e todas as cópias guardadas nela, e não dá para desfazer. Enviamos um link para o seu e-mail, e nada é apagado até você apertar o botão de lá.",
removeAccountSend: "Enviar o link",
removeAccountSent: "Enviamos um link para o seu e-mail. Abra e aperte o botão para terminar.",
+ auto: {
+ title: "Cópias automáticas",
+ intro:
+ "O Botloft pode mandar uma cópia para a nuvem sozinho, e só quando algo mudou. A senha fica guardada no cofre de credenciais do Windows, só neste computador.",
+ tip: "Use uma senha só para isto, que você não use em nenhum outro lugar.",
+ every: "Com que frequência",
+ daily: "Todo dia",
+ weekly: "Toda semana",
+ turnOn: "Ligar",
+ turningOn: "Ligando…",
+ turnOff: "Desligar",
+ onDaily: "Ligado: uma cópia por dia, se algo mudou.",
+ onWeekly: "Ligado: uma cópia por semana, se algo mudou.",
+ lastCopy: (when: string) => `Última cópia enviada: ${when}.`,
+ noneYet: "Nenhuma cópia enviada ainda.",
+ next: (when: string) => `Próxima vez que olha: ${when}.`,
+ unavailable: "As cópias automáticas só funcionam no Windows por enquanto.",
+ failed: "Não foi possível ligar",
+ lastFailed: "A última tentativa falhou",
+ },
failed: "Algo deu errado",
reasons: {
no_server: "Este Botloft ainda não tem a nuvem configurada.",
@@ -152,6 +172,9 @@ export const account: Messages["account"] = {
not_found: "Essa cópia não está mais lá.",
bad_hash: "A cópia se danificou no caminho. Tente de novo.",
cloud_error: "Algo deu errado com a nuvem. Tente de novo daqui a pouco.",
+ no_keystore: "Este computador não tem onde guardar a senha com segurança.",
+ no_passphrase:
+ "A senha sumiu deste computador, então as cópias automáticas pararam. Ligue de novo.",
} as Record,
},
settings: {
diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts
index 3e3b954a..5e41da56 100644
--- a/app/src/lib/fake.ts
+++ b/app/src/lib/fake.ts
@@ -4,6 +4,7 @@
import type { BotloftApi } from "./api";
import { FakeAllow } from "./fakeAllow";
+import { autoBackupHandlers } from "./fakeAutoBackup";
import { backupHandlers } from "./fakeBackup";
import { botHandlers } from "./fakeBots";
import { FakeBrowser } from "./fakeBrowser";
@@ -291,6 +292,7 @@ export class FakeBotloft implements BotloftApi {
...usageHandlers(this),
...backupHandlers(this),
...cloudHandlers(this),
+ ...autoBackupHandlers(this),
...searchHandlers(this),
};
}
diff --git a/app/src/lib/fakeAutoBackup.ts b/app/src/lib/fakeAutoBackup.ts
new file mode 100644
index 00000000..d9765401
--- /dev/null
+++ b/app/src/lib/fakeAutoBackup.ts
@@ -0,0 +1,88 @@
+// The fake daemon's automatic backup (spec 27.10), in memory. The page never
+// sends anything: `setFakeAutoBackup` plays what the real loop would report.
+
+import type { FakeBotloft, Handlers } from "./fake";
+import { type AutoBackupEvery, type AutoBackupStatus, RpcErrorCode } from "./protocol.gen";
+import { RpcError } from "./rpc";
+
+type AutoMethods = Extract;
+
+const DAY = 24 * 3600 * 1000;
+
+const states = new WeakMap();
+
+function stateOf(fake: FakeBotloft): AutoBackupStatus {
+ let state = states.get(fake);
+ if (!state) {
+ state = { available: true, enabled: false, every: "daily" };
+ states.set(fake, state);
+ }
+ return state;
+}
+
+function told(fake: FakeBotloft): AutoBackupStatus {
+ const status = { ...stateOf(fake) };
+ fake.emit({ name: "autobackup.changed", params: status });
+ return status;
+}
+
+/** Plays a change the loop makes, such as a copy sent or a failed try. */
+export function setFakeAutoBackup(fake: FakeBotloft, patch: Partial): void {
+ Object.assign(stateOf(fake), patch);
+ told(fake);
+}
+
+/** Signing out of the account turns it off. */
+export function turnOffFakeAutoBackup(fake: FakeBotloft): void {
+ const state = stateOf(fake);
+ state.enabled = false;
+ delete state.nextAt;
+}
+
+export function autoBackupHandlers(fake: FakeBotloft): Pick {
+ const state = stateOf(fake);
+ const every = (value: AutoBackupEvery) => {
+ state.every = value;
+ };
+ return {
+ "autobackup.status": () => ({ ...state }),
+ "autobackup.enable": ({ passphrase, every: how }) => {
+ if (!state.available) {
+ throw new RpcError(
+ RpcErrorCode.validation,
+ "no safe place for the passphrase",
+ "no_keystore",
+ );
+ }
+ if ([...passphrase].length < 8) {
+ throw new RpcError(
+ RpcErrorCode.validation,
+ "the passphrase must have at least 8 characters",
+ "short_passphrase",
+ );
+ }
+ every(how);
+ state.enabled = true;
+ state.nextAt = fake.now;
+ delete state.lastError;
+ return told(fake);
+ },
+ "autobackup.set_every": ({ every: how }) => {
+ every(how);
+ return told(fake);
+ },
+ "autobackup.disable": () => {
+ turnOffFakeAutoBackup(fake);
+ return told(fake);
+ },
+ };
+}
+
+/** What a copy sent just now looks like to the screen. */
+export function fakeAutoBackupSent(fake: FakeBotloft): void {
+ const state = stateOf(fake);
+ setFakeAutoBackup(fake, {
+ lastOkAt: fake.now,
+ nextAt: fake.now + (state.every === "weekly" ? 7 * DAY : DAY),
+ });
+}
diff --git a/app/src/lib/fakeCloud.ts b/app/src/lib/fakeCloud.ts
index 24bddef9..0db3d9b6 100644
--- a/app/src/lib/fakeCloud.ts
+++ b/app/src/lib/fakeCloud.ts
@@ -3,6 +3,7 @@
// the e-mail. Nothing leaves the page.
import type { FakeBotloft, Handlers } from "./fake";
+import { turnOffFakeAutoBackup } from "./fakeAutoBackup";
import { type CloudCopy, type CloudStatus, RpcErrorCode } from "./protocol.gen";
import { RpcError } from "./rpc";
@@ -85,6 +86,7 @@ export function cloudHandlers(fake: FakeBotloft): Pick {
return null;
},
"cloud.signout": () => {
+ turnOffFakeAutoBackup(fake);
state.signedIn = false;
state.email = null;
state.pending = null;
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index 2718da46..b1742d49 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -1304,6 +1304,43 @@ export type CloudDirection = "upload" | "download";
*/
export type CloudProgress = { direction: CloudDirection, sent: number, total: number, };
+/**
+ * How often the automatic backup looks for something new to send (spec 27.10).
+ */
+export type AutoBackupEvery = "daily" | "weekly";
+
+/**
+ * `autobackup.status` and the `autobackup.changed` notification.
+ */
+export type AutoBackupStatus = {
+/**
+ * This computer can keep the passphrase safely (Windows today).
+ */
+available: boolean, enabled: boolean, every: AutoBackupEvery,
+/**
+ * Unix time in milliseconds of the last copy it sent.
+ */
+lastOkAt?: number,
+/**
+ * Unix time in milliseconds of the next look; absent when it is off.
+ */
+nextAt?: number,
+/**
+ * Why the last try failed (a `reason` of 27.5, or `no_passphrase`).
+ */
+lastError?: string, };
+
+/**
+ * `autobackup.enable`: the passphrase is kept in the system's credential
+ * store, and never leaves this computer.
+ */
+export type AutoBackupEnableParams = { passphrase: string, every: AutoBackupEvery, };
+
+/**
+ * `autobackup.set_every`
+ */
+export type AutoBackupEveryParams = { every: AutoBackupEvery, };
+
export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string,
/**
* What the bot says the command is for, as in `ToolItem`. The bot
@@ -2023,6 +2060,10 @@ export interface RpcMethods {
"cloud.download": { params: CloudCopyParams; result: CloudDownloaded };
"cloud.delete": { params: CloudCopyParams; result: null };
"cloud.delete_account": { params: undefined; result: null };
+ "autobackup.status": { params: undefined; result: AutoBackupStatus };
+ "autobackup.enable": { params: AutoBackupEnableParams; result: AutoBackupStatus };
+ "autobackup.set_every": { params: AutoBackupEveryParams; result: AutoBackupStatus };
+ "autobackup.disable": { params: undefined; result: AutoBackupStatus };
"routines.list": { params: RoutinesListParams; result: Array };
"routines.create": { params: RoutinesCreateParams; result: Routine };
"routines.update": { params: RoutinesUpdateParams; result: Routine };
@@ -2081,6 +2122,7 @@ export interface RpcNotifications {
"cloud.signed_in": CloudSignedIn;
"cloud.progress": CloudProgress;
"cloud.signin_expired": null;
+ "autobackup.changed": AutoBackupStatus;
"browser.changed": BrowserState;
"browser.action": BrowserAction;
"browser.frame": BrowserFrame;
diff --git a/crates/botloft-core/src/protocol/cloud.rs b/crates/botloft-core/src/protocol/cloud.rs
index 519f12bf..b758ee9e 100644
--- a/crates/botloft-core/src/protocol/cloud.rs
+++ b/crates/botloft-core/src/protocol/cloud.rs
@@ -10,12 +10,15 @@ pub struct CloudStatus {
/// The server the app talks to; empty when none is set up.
pub url: String,
pub signed_in: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(test, ts(optional))]
pub email: Option,
/// Bytes the account's copies take, when the server answered.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(test, ts(optional))]
pub used: Option,
/// Bytes the account may keep, when the server answered.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(test, ts(optional))]
pub quota: Option,
/// A sign-in waits for the owner to open the link in the e-mail.
@@ -112,3 +115,86 @@ pub struct CloudProgress {
pub sent: u64,
pub total: u64,
}
+
+/// How often the automatic backup looks for something new to send (spec 27.10).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub enum AutoBackupEvery {
+ #[default]
+ Daily,
+ Weekly,
+}
+
+/// `autobackup.status` and the `autobackup.changed` notification.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct AutoBackupStatus {
+ /// This computer can keep the passphrase safely (Windows today).
+ pub available: bool,
+ pub enabled: bool,
+ pub every: AutoBackupEvery,
+ /// Unix time in milliseconds of the last copy it sent.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[cfg_attr(test, ts(optional))]
+ pub last_ok_at: Option,
+ /// Unix time in milliseconds of the next look; absent when it is off.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[cfg_attr(test, ts(optional))]
+ pub next_at: Option,
+ /// Why the last try failed (a `reason` of 27.5, or `no_passphrase`).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[cfg_attr(test, ts(optional))]
+ pub last_error: Option,
+}
+
+/// `autobackup.enable`: the passphrase is kept in the system's credential
+/// store, and never leaves this computer.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct AutoBackupEnableParams {
+ pub passphrase: String,
+ pub every: AutoBackupEvery,
+}
+
+/// `autobackup.set_every`
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct AutoBackupEveryParams {
+ pub every: AutoBackupEvery,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn what_is_absent_is_left_out_of_the_json_not_sent_as_null() {
+ let off = AutoBackupStatus {
+ available: true,
+ enabled: false,
+ every: AutoBackupEvery::Daily,
+ last_ok_at: None,
+ next_at: None,
+ last_error: None,
+ };
+ let json = serde_json::to_value(&off).expect("json");
+ assert_eq!(
+ json,
+ serde_json::json!({ "available": true, "enabled": false, "every": "daily" })
+ );
+ let out = CloudStatus {
+ url: "u".to_owned(),
+ signed_in: false,
+ email: None,
+ used: None,
+ quota: None,
+ pending: false,
+ };
+ let json = serde_json::to_value(&out).expect("json");
+ assert!(json.get("email").is_none() && json.get("used").is_none());
+ }
+}
diff --git a/crates/botloft-core/src/protocol/export_cloud.rs b/crates/botloft-core/src/protocol/export_cloud.rs
index 1e49db6e..de5f0092 100644
--- a/crates/botloft-core/src/protocol/export_cloud.rs
+++ b/crates/botloft-core/src/protocol/export_cloud.rs
@@ -17,6 +17,10 @@ pub(super) fn decls(out: &mut Out) {
out.decl::();
out.decl::();
out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
}
pub(super) fn methods(out: &mut Out) {
@@ -49,6 +53,26 @@ pub(super) fn methods(out: &mut Out) {
);
out.method(method::CLOUD_DELETE, &out.name::(), "null");
out.method(method::CLOUD_DELETE_ACCOUNT, "undefined", "null");
+ out.method(
+ method::AUTOBACKUP_STATUS,
+ "undefined",
+ &out.name::(),
+ );
+ out.method(
+ method::AUTOBACKUP_ENABLE,
+ &out.name::(),
+ &out.name::(),
+ );
+ out.method(
+ method::AUTOBACKUP_SET_EVERY,
+ &out.name::(),
+ &out.name::(),
+ );
+ out.method(
+ method::AUTOBACKUP_DISABLE,
+ "undefined",
+ &out.name::(),
+ );
}
pub(super) fn notifications(out: &mut Out) {
@@ -69,4 +93,10 @@ pub(super) fn notifications(out: &mut Out) {
" \"{}\": null;",
notification::CLOUD_SIGNIN_EXPIRED
);
+ let changed = out.name::();
+ let _ = writeln!(
+ out.text,
+ " \"{}\": {changed};",
+ notification::AUTOBACKUP_CHANGED
+ );
}
diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs
index a0b0c237..725f6cf1 100644
--- a/crates/botloft-core/src/protocol/mod.rs
+++ b/crates/botloft-core/src/protocol/mod.rs
@@ -150,6 +150,10 @@ pub mod method {
pub const CLOUD_DOWNLOAD: &str = "cloud.download";
pub const CLOUD_DELETE: &str = "cloud.delete";
pub const CLOUD_DELETE_ACCOUNT: &str = "cloud.delete_account";
+ pub const AUTOBACKUP_STATUS: &str = "autobackup.status";
+ pub const AUTOBACKUP_ENABLE: &str = "autobackup.enable";
+ pub const AUTOBACKUP_SET_EVERY: &str = "autobackup.set_every";
+ pub const AUTOBACKUP_DISABLE: &str = "autobackup.disable";
pub const CREW_ACCESS_LIST: &str = "crewAccess.list";
pub const CREW_ACCESS_REVOKE: &str = "crewAccess.revoke";
pub const MCP_SERVERS: &str = "mcp.servers";
@@ -242,6 +246,7 @@ pub mod notification {
pub const CLOUD_SIGNED_IN: &str = "cloud.signed_in";
pub const CLOUD_PROGRESS: &str = "cloud.progress";
pub const CLOUD_SIGNIN_EXPIRED: &str = "cloud.signin_expired";
+ pub const AUTOBACKUP_CHANGED: &str = "autobackup.changed";
}
/// Error codes: the standard JSON-RPC ones plus the daemon's (spec 11.4).
diff --git a/crates/botloft-store/Cargo.toml b/crates/botloft-store/Cargo.toml
index c67d4a27..b3f1b2e1 100644
--- a/crates/botloft-store/Cargo.toml
+++ b/crates/botloft-store/Cargo.toml
@@ -12,6 +12,7 @@ botloft-core.workspace = true
rusqlite = { version = "0.40.2", features = ["bundled"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
+sha2 = "0.11.0"
thiserror = "2.0.21"
[lints]
diff --git a/crates/botloft-store/src/fingerprint.rs b/crates/botloft-store/src/fingerprint.rs
new file mode 100644
index 00000000..d26b61ae
--- /dev/null
+++ b/crates/botloft-store/src/fingerprint.rs
@@ -0,0 +1,154 @@
+//! What the light copy holds, as one hash (spec 27.10): the automatic backup
+//! sends a copy only when this changed since the last one it sent.
+
+use std::path::Path;
+
+use rusqlite::Connection;
+use rusqlite::types::ValueRef;
+use sha2::{Digest, Sha256};
+
+use crate::{Result, Store};
+
+/// What changes by itself while nothing the owner made does: the model and
+/// effort Claude Code reported, and when a routine runs next. Left in, the
+/// copy would look new every few minutes. A column added later that also
+/// moves by itself only costs a copy sent too often, never one missed.
+const BY_THEMSELVES: &[&str] = &[
+ "UPDATE bots SET model_in_use = NULL, effort_default = NULL",
+ "UPDATE routines SET next_run_at = NULL",
+];
+
+/// The search index mirrors the chats, which the light copy leaves out.
+fn skipped(table: &str) -> bool {
+ table.starts_with("sqlite_") || table.starts_with("chat_search")
+}
+
+impl Store {
+ /// A hash of the light copy of the database (14.2), hex. `scratch` is a
+ /// file name that may not exist: the copy is made there and removed.
+ pub fn light_fingerprint(&self, scratch: &Path) -> Result {
+ let _ = std::fs::remove_file(scratch);
+ self.snapshot_light_to(scratch)?;
+ let hash = hash_tables(scratch);
+ let _ = std::fs::remove_file(scratch);
+ hash
+ }
+}
+
+fn hash_tables(path: &Path) -> Result {
+ let copy = Connection::open(path)?;
+ for update in BY_THEMSELVES {
+ copy.execute(update, [])?;
+ }
+ let tables: Vec = copy
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")?
+ .query_map([], |row| row.get(0))?
+ .collect::>()?;
+ let mut hash = Sha256::new();
+ for table in tables.iter().filter(|table| !skipped(table)) {
+ hash.update(table.as_bytes());
+ let columns = copy
+ .prepare(&format!("SELECT * FROM \"{table}\" LIMIT 0"))?
+ .column_count();
+ let order = (1..=columns)
+ .map(|column| column.to_string())
+ .collect::>()
+ .join(", ");
+ let mut rows = copy.prepare(&format!("SELECT * FROM \"{table}\" ORDER BY {order}"))?;
+ let mut rows = rows.query([])?;
+ while let Some(row) = rows.next()? {
+ hash.update([0xFF]);
+ for column in 0..columns {
+ match row.get_ref(column)? {
+ ValueRef::Null => hash.update([0]),
+ ValueRef::Integer(value) => {
+ hash.update([1]);
+ hash.update(value.to_le_bytes());
+ }
+ ValueRef::Real(value) => {
+ hash.update([2]);
+ hash.update(value.to_le_bytes());
+ }
+ ValueRef::Text(bytes) | ValueRef::Blob(bytes) => {
+ hash.update([3]);
+ hash.update((bytes.len() as u64).to_le_bytes());
+ hash.update(bytes);
+ }
+ }
+ }
+ }
+ }
+ Ok(hash
+ .finalize()
+ .iter()
+ .map(|byte| format!("{byte:02x}"))
+ .collect())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn store() -> (tempfile::TempDir, Store) {
+ let dir = tempfile::tempdir().expect("dir");
+ let store = Store::open(&dir.path().join("a.db")).expect("store");
+ store
+ .conn
+ .execute_batch(
+ "INSERT INTO crews (id, name, slug, created_at) VALUES ('crw_1', 'Ops', 'ops', 1);
+ INSERT INTO bots (id, crew_id, name, handle, slug, role, instructions, color, \
+ created_at)
+ VALUES ('bot_1', 'crw_1', 'Scout', 'scout', 'scout', 'r', 'i', 'c', 1);
+ INSERT INTO routines (id, bot_id, name, prompt, schedule, timezone, overlap, \
+ missed, enabled, next_run_at, created_at, updated_at)
+ VALUES ('rtn_1', 'bot_1', 'Morning', 'p', '{}', 'UTC', 'skip', 'skip', 1, 10, 1, 1);",
+ )
+ .expect("rows");
+ (dir, store)
+ }
+
+ fn print(dir: &tempfile::TempDir, store: &Store) -> String {
+ store
+ .light_fingerprint(&dir.path().join("scratch.db"))
+ .expect("fingerprint")
+ }
+
+ #[test]
+ fn what_changes_by_itself_does_not_change_the_fingerprint() {
+ let (dir, store) = store();
+ let first = print(&dir, &store);
+ assert_eq!(first.len(), 64);
+ assert_eq!(print(&dir, &store), first);
+ store
+ .conn
+ .execute_batch(
+ "UPDATE bots SET model_in_use = 'claude-opus-5-5', effort_default = 'medium';
+ UPDATE routines SET next_run_at = 99;
+ INSERT INTO turn_costs (bot_id, at, cost) VALUES ('bot_1', 1, 0.5);",
+ )
+ .expect("by themselves");
+ assert_eq!(print(&dir, &store), first);
+ // The scratch copy does not stay behind.
+ assert!(!dir.path().join("scratch.db").exists());
+ }
+
+ #[test]
+ fn what_the_owner_made_changes_it() {
+ let (dir, store) = store();
+ let first = print(&dir, &store);
+ store
+ .conn
+ .execute(
+ "UPDATE bots SET instructions = 'new' WHERE id = 'bot_1'",
+ [],
+ )
+ .expect("edit");
+ let edited = print(&dir, &store);
+ assert_ne!(edited, first);
+ store
+ .conn
+ .execute("UPDATE routines SET enabled = 0", [])
+ .expect("routine");
+ assert_ne!(print(&dir, &store), edited);
+ }
+}
diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs
index fd5327a1..6634d699 100644
--- a/crates/botloft-store/src/lib.rs
+++ b/crates/botloft-store/src/lib.rs
@@ -14,6 +14,7 @@ mod crews;
mod delete;
mod deliveries;
mod desktop_grants;
+mod fingerprint;
mod light;
mod mcp_servers;
mod messages;
diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml
index c17216d3..3ecf518b 100644
--- a/crates/botloftd/Cargo.toml
+++ b/crates/botloftd/Cargo.toml
@@ -41,7 +41,7 @@ zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
workspace = true
[target."cfg(windows)".dependencies]
-windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_System_RemoteDesktop", "Win32_Security_Authentication_Identity", "Win32_UI_Shell", "Win32_UI_Shell_Common", "Win32_UI_WindowsAndMessaging", "Win32_UI_HiDpi", "Win32_Graphics_Gdi", "Win32_Graphics_Dwm", "Win32_Storage_FileSystem", "Win32_Storage_Xps", "Win32_System_LibraryLoader", "Win32_UI_Accessibility", "Win32_UI_Input_KeyboardAndMouse", "Win32_System_SystemInformation"] }
+windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_System_RemoteDesktop", "Win32_Security_Authentication_Identity", "Win32_UI_Shell", "Win32_UI_Shell_Common", "Win32_UI_WindowsAndMessaging", "Win32_UI_HiDpi", "Win32_Graphics_Gdi", "Win32_Graphics_Dwm", "Win32_Storage_FileSystem", "Win32_Storage_Xps", "Win32_System_LibraryLoader", "Win32_UI_Accessibility", "Win32_UI_Input_KeyboardAndMouse", "Win32_System_SystemInformation", "Win32_Security_Credentials"] }
# Named by the code that `windows::core::implement` writes.
windows-core = "0.62.2"
diff --git a/crates/botloftd/src/autobackup/mod.rs b/crates/botloftd/src/autobackup/mod.rs
new file mode 100644
index 00000000..f1ad0ad1
--- /dev/null
+++ b/crates/botloftd/src/autobackup/mod.rs
@@ -0,0 +1,109 @@
+//! The automatic backup (spec 27.10): once the owner turns it on, a light
+//! copy goes to the cloud every day or week, and only when something the
+//! owner made has changed. It seals with the owner's passphrase, kept in the
+//! system's credential store, and stops by itself when the account or the
+//! passphrase is gone.
+
+mod run;
+mod secrets;
+mod state;
+
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex, MutexGuard};
+use std::time::Duration;
+
+use botloft_core::protocol::AutoBackupStatus;
+use tokio::sync::Notify;
+use tracing::warn;
+
+pub use self::run::{run, tick};
+pub use self::secrets::{MemorySecrets, SecretStore, SystemSecrets};
+pub(crate) use self::state::{Saved, period};
+
+/// How often it looks whether its time has come.
+pub const DEFAULT_TICK: Duration = Duration::from_secs(60);
+
+pub struct AutoBackup {
+ file: PathBuf,
+ saved: Mutex,
+ secrets: Arc,
+ wake: Notify,
+ tick: Duration,
+}
+
+impl AutoBackup {
+ pub fn new(home: &Path, secrets: Arc, tick: Duration) -> Self {
+ let file = home.join("autobackup.json");
+ Self {
+ saved: Mutex::new(state::load(&file)),
+ file,
+ secrets,
+ wake: Notify::new(),
+ tick,
+ }
+ }
+
+ pub fn secrets(&self) -> &dyn SecretStore {
+ &*self.secrets
+ }
+
+ /// Looks now instead of at the next tick: it was turned on or changed.
+ pub fn wake(&self) {
+ self.wake.notify_one();
+ }
+
+ pub fn snapshot(&self) -> Saved {
+ self.lock().clone()
+ }
+
+ /// Changes what it remembers and saves it.
+ pub(crate) fn update(&self, change: impl FnOnce(&mut Saved)) {
+ let mut saved = self.lock();
+ change(&mut saved);
+ if let Err(err) = state::save(&self.file, &saved) {
+ warn!("could not save the automatic backup's state: {err}");
+ }
+ }
+
+ pub fn status(&self) -> AutoBackupStatus {
+ let saved = self.snapshot();
+ AutoBackupStatus {
+ available: self.secrets.available(),
+ enabled: saved.enabled,
+ every: saved.every,
+ last_ok_at: saved.last_ok_at,
+ next_at: saved.enabled.then_some(saved.next_at).flatten(),
+ last_error: saved.last_error,
+ }
+ }
+
+ /// A manual copy that held `fingerprint` went up at `now`: the next look
+ /// has nothing to send until something changes.
+ pub(crate) fn covered(&self, fingerprint: String, now: i64) {
+ let wait = period(self.snapshot().every);
+ self.update(|saved| {
+ saved.fingerprint = Some(fingerprint);
+ saved.last_ok_at = Some(now);
+ saved.next_at = Some(crate::clock::after(now, wait));
+ saved.last_error = None;
+ });
+ }
+
+ /// Off, with the passphrase forgotten.
+ pub fn turn_off(&self) {
+ if let Err(err) = self.secrets.delete() {
+ warn!("could not remove the automatic backup's passphrase: {err}");
+ }
+ self.update(|saved| {
+ saved.enabled = false;
+ saved.fingerprint = None;
+ saved.next_at = None;
+ });
+ }
+
+ fn lock(&self) -> MutexGuard<'_, Saved> {
+ self.saved
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+}
diff --git a/crates/botloftd/src/autobackup/run.rs b/crates/botloftd/src/autobackup/run.rs
new file mode 100644
index 00000000..ffbd679d
--- /dev/null
+++ b/crates/botloftd/src/autobackup/run.rs
@@ -0,0 +1,206 @@
+//! The loop of the automatic backup (spec 27.10).
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use botloft_core::protocol::AutoBackupEvery;
+use tracing::info;
+
+use super::{Saved, period};
+use crate::backup::fingerprint::light_fingerprint;
+use crate::service::{ApiError, cloud};
+use crate::state::{Daemon, Event};
+
+/// After a failure that may pass by itself, such as being offline.
+const SOON: Duration = Duration::from_secs(15 * 60);
+/// After one that needs the owner, such as signing in again.
+const LATER: Duration = Duration::from_secs(3600);
+
+/// Runs until the daemon stops.
+pub async fn run(daemon: Arc) {
+ loop {
+ let pass = Arc::clone(&daemon);
+ let _ = tokio::task::spawn_blocking(move || tick(&pass)).await;
+ tokio::select! {
+ () = tokio::time::sleep(daemon.autobackup.tick) => {}
+ () = daemon.autobackup.wake.notified() => {}
+ }
+ }
+}
+
+enum Outcome {
+ Sent(String),
+ /// Nothing the owner made changed since the last copy.
+ Unchanged,
+ Failed {
+ reason: String,
+ retry: Duration,
+ /// Nothing more to do until the owner turns it on again.
+ turn_off: bool,
+ },
+}
+
+fn failed(reason: &str, retry: Duration) -> Outcome {
+ Outcome::Failed {
+ reason: reason.to_owned(),
+ retry,
+ turn_off: false,
+ }
+}
+
+/// One pass: sends a copy if it is on, its time has come and something
+/// changed. It blocks (disk, network), so it runs on the blocking pool; tests
+/// call it after moving the clock.
+pub fn tick(daemon: &Daemon) {
+ let saved = daemon.autobackup.snapshot();
+ let now = daemon.clock.now_ms();
+ if !saved.enabled || saved.next_at.is_some_and(|at| now < at) {
+ return;
+ }
+ let outcome = attempt(daemon, &saved);
+ daemon
+ .autobackup
+ .update(|state| record(state, &outcome, saved.every, now));
+ if matches!(outcome, Outcome::Failed { turn_off: true, .. }) {
+ daemon.autobackup.turn_off();
+ }
+ daemon.emit(Event::AutoBackupChanged(daemon.autobackup.status()));
+}
+
+fn after(at: i64, by: Duration) -> i64 {
+ crate::clock::after(at, by)
+}
+
+/// What the pass leaves behind. `sent_every` is how often it was set when the
+/// pass began: the owner may have changed it while a copy went up, and that
+/// wins, so the next look comes soon and counts the new period.
+fn record(state: &mut Saved, outcome: &Outcome, sent_every: AutoBackupEvery, now: i64) {
+ let unchanged = state.every == sent_every;
+ let next = |by: Duration| unchanged.then(|| after(now, by));
+ match outcome {
+ Outcome::Sent(fingerprint) => {
+ state.last_ok_at = Some(now);
+ state.fingerprint = Some(fingerprint.clone());
+ state.next_at = next(period(state.every)).or(state.next_at);
+ state.last_error = None;
+ }
+ Outcome::Unchanged => {
+ state.next_at = next(period(state.every)).or(state.next_at);
+ state.last_error = None;
+ }
+ Outcome::Failed { reason, retry, .. } => {
+ state.next_at = next(*retry).or(state.next_at);
+ state.last_error = Some(reason.clone());
+ }
+ }
+}
+
+fn attempt(daemon: &Daemon, saved: &Saved) -> Outcome {
+ if daemon.cloud.credentials(&daemon.paths.secrets()).is_none() {
+ return failed("not_signed_in", LATER);
+ }
+ let passphrase = match daemon.autobackup.secrets().load() {
+ Ok(Some(passphrase)) => passphrase,
+ // Gone from the credential store: it cannot go on.
+ Ok(None) | Err(_) => {
+ return Outcome::Failed {
+ reason: "no_passphrase".to_owned(),
+ retry: LATER,
+ turn_off: true,
+ };
+ }
+ };
+ let Ok(fingerprint) = light_fingerprint(daemon) else {
+ return failed("cloud_error", LATER);
+ };
+ if saved.last_ok_at.is_some() && saved.fingerprint.as_deref() == Some(&fingerprint) {
+ return Outcome::Unchanged;
+ }
+ match cloud::send_light(daemon, &passphrase) {
+ Ok(copy) => {
+ info!(bytes = copy.size, "an automatic copy went up to the cloud");
+ Outcome::Sent(fingerprint)
+ }
+ Err(ApiError::Rule { reason, .. }) => {
+ // A copy too big or an account too full stays so until the owner
+ // frees space, so it waits for the next turn rather than trying
+ // again soon.
+ let retry = match reason {
+ "offline" | "cloud_error" | "rate_limited" => SOON,
+ "quota" | "too_big" => period(saved.every),
+ _ => LATER,
+ };
+ failed(reason, retry)
+ }
+ Err(_) => failed("cloud_error", LATER),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const NOW: i64 = 1_000_000;
+
+ fn on(every: AutoBackupEvery) -> Saved {
+ Saved {
+ enabled: true,
+ every,
+ ..Saved::default()
+ }
+ }
+
+ #[test]
+ fn a_sent_copy_waits_one_period() {
+ let mut state = on(AutoBackupEvery::Daily);
+ record(
+ &mut state,
+ &Outcome::Sent("ab".to_owned()),
+ AutoBackupEvery::Daily,
+ NOW,
+ );
+ assert_eq!(state.last_ok_at, Some(NOW));
+ assert_eq!(state.fingerprint.as_deref(), Some("ab"));
+ assert_eq!(state.next_at, Some(NOW + 86_400_000));
+ }
+
+ #[test]
+ fn a_change_of_period_made_during_the_pass_is_not_undone() {
+ // The pass began as daily; the owner chose weekly and asked it to look again.
+ let mut state = on(AutoBackupEvery::Weekly);
+ state.next_at = None;
+ record(
+ &mut state,
+ &Outcome::Sent("ab".to_owned()),
+ AutoBackupEvery::Daily,
+ NOW,
+ );
+ assert_eq!(state.last_ok_at, Some(NOW));
+ assert_eq!(state.next_at, None);
+ record(&mut state, &Outcome::Unchanged, AutoBackupEvery::Daily, NOW);
+ assert_eq!(state.next_at, None);
+ // The next pass, begun as weekly, counts the new period.
+ record(
+ &mut state,
+ &Outcome::Unchanged,
+ AutoBackupEvery::Weekly,
+ NOW,
+ );
+ assert_eq!(state.next_at, Some(NOW + 7 * 86_400_000));
+ }
+
+ #[test]
+ fn a_failure_is_kept_with_when_to_try_again() {
+ let mut state = on(AutoBackupEvery::Daily);
+ let failed = Outcome::Failed {
+ reason: "offline".to_owned(),
+ retry: SOON,
+ turn_off: false,
+ };
+ record(&mut state, &failed, AutoBackupEvery::Daily, NOW);
+ assert_eq!(state.last_error.as_deref(), Some("offline"));
+ assert_eq!(state.next_at, Some(NOW + 900_000));
+ record(&mut state, &Outcome::Unchanged, AutoBackupEvery::Daily, NOW);
+ assert_eq!(state.last_error, None);
+ }
+}
diff --git a/crates/botloftd/src/autobackup/secrets.rs b/crates/botloftd/src/autobackup/secrets.rs
new file mode 100644
index 00000000..61d49b62
--- /dev/null
+++ b/crates/botloftd/src/autobackup/secrets.rs
@@ -0,0 +1,132 @@
+//! Where the automatic backup keeps its passphrase (spec 27.10): the
+//! system's credential store, behind a trait so tests keep it in memory.
+
+use std::io;
+use std::path::Path;
+use std::sync::Mutex;
+
+use sha2::{Digest, Sha256};
+
+use crate::platform;
+
+pub trait SecretStore: Send + Sync {
+ /// This computer has somewhere safe to keep a secret.
+ fn available(&self) -> bool;
+ fn save(&self, secret: &str) -> io::Result<()>;
+ fn load(&self) -> io::Result>;
+ fn delete(&self) -> io::Result<()>;
+}
+
+/// The credential store of the system, under a name of this data folder: a
+/// development folder and the real one never share a secret.
+pub struct SystemSecrets {
+ target: String,
+}
+
+impl SystemSecrets {
+ pub fn for_home(home: &Path) -> Self {
+ let digest = Sha256::digest(home.to_string_lossy().to_lowercase().as_bytes());
+ let short: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
+ Self {
+ target: format!("Botloft backup passphrase {short}"),
+ }
+ }
+}
+
+impl SecretStore for SystemSecrets {
+ fn available(&self) -> bool {
+ platform::secret_available()
+ }
+
+ fn save(&self, secret: &str) -> io::Result<()> {
+ platform::secret_save(&self.target, secret)
+ }
+
+ fn load(&self) -> io::Result > {
+ platform::secret_load(&self.target)
+ }
+
+ fn delete(&self) -> io::Result<()> {
+ platform::secret_delete(&self.target)
+ }
+}
+
+/// A store in memory, for tests and for a system without one (`available`
+/// says whether it keeps anything).
+pub struct MemorySecrets {
+ available: bool,
+ secret: Mutex >,
+}
+
+impl MemorySecrets {
+ pub fn new() -> Self {
+ Self {
+ available: true,
+ secret: Mutex::new(None),
+ }
+ }
+
+ /// A system with nowhere to keep a secret.
+ pub fn unavailable() -> Self {
+ Self {
+ available: false,
+ secret: Mutex::new(None),
+ }
+ }
+}
+
+impl Default for MemorySecrets {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl SecretStore for MemorySecrets {
+ fn available(&self) -> bool {
+ self.available
+ }
+
+ fn save(&self, secret: &str) -> io::Result<()> {
+ *self.secret.lock().unwrap_or_else(|p| p.into_inner()) = Some(secret.to_owned());
+ Ok(())
+ }
+
+ fn load(&self) -> io::Result > {
+ Ok(self
+ .secret
+ .lock()
+ .unwrap_or_else(|p| p.into_inner())
+ .clone())
+ }
+
+ fn delete(&self) -> io::Result<()> {
+ *self.secret.lock().unwrap_or_else(|p| p.into_inner()) = None;
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn each_data_folder_has_its_own_name() {
+ let a = SystemSecrets::for_home(Path::new(r"C:\Users\ana\AppData\Local\Botloft"));
+ let same = SystemSecrets::for_home(Path::new(r"c:\users\ana\appdata\local\botloft"));
+ let dev = SystemSecrets::for_home(Path::new(r"C:\code\.dev\home"));
+ assert_eq!(a.target, same.target);
+ assert_ne!(a.target, dev.target);
+ assert!(a.target.starts_with("Botloft backup passphrase "));
+ }
+
+ #[test]
+ fn the_memory_store_keeps_and_forgets() {
+ let store = MemorySecrets::new();
+ assert_eq!(store.load().expect("empty"), None);
+ store.save("secret").expect("save");
+ assert_eq!(store.load().expect("load").as_deref(), Some("secret"));
+ store.delete().expect("delete");
+ assert_eq!(store.load().expect("gone"), None);
+ assert!(!MemorySecrets::unavailable().available());
+ }
+}
diff --git a/crates/botloftd/src/autobackup/state.rs b/crates/botloftd/src/autobackup/state.rs
new file mode 100644
index 00000000..d24c84af
--- /dev/null
+++ b/crates/botloftd/src/autobackup/state.rs
@@ -0,0 +1,79 @@
+//! What the automatic backup remembers between runs, in `autobackup.json`
+//! in the data folder (spec 27.10). The passphrase is not here: it is in the
+//! credential store.
+
+use std::fs;
+use std::io;
+use std::path::Path;
+use std::time::Duration;
+
+use botloft_core::protocol::AutoBackupEvery;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Saved {
+ pub enabled: bool,
+ pub every: AutoBackupEvery,
+ /// When the last copy this made went up.
+ pub last_ok_at: Option,
+ /// What the light copy held when it last went up (or was last seen
+ /// unchanged), to tell whether there is something new to send.
+ pub fingerprint: Option,
+ /// Not before this time; absent means as soon as it can.
+ pub next_at: Option,
+ /// The `reason` of the last failure; cleared by the next success.
+ pub last_error: Option,
+}
+
+/// How long it waits after a copy was sent or found unchanged.
+pub fn period(every: AutoBackupEvery) -> Duration {
+ match every {
+ AutoBackupEvery::Daily => Duration::from_secs(24 * 3600),
+ AutoBackupEvery::Weekly => Duration::from_secs(7 * 24 * 3600),
+ }
+}
+
+/// What is stored; nothing stored, or a file that cannot be read, is off.
+pub fn load(file: &Path) -> Saved {
+ fs::read(file)
+ .ok()
+ .and_then(|bytes| serde_json::from_slice(&bytes).ok())
+ .unwrap_or_default()
+}
+
+pub fn save(file: &Path, saved: &Saved) -> io::Result<()> {
+ let tmp = file.with_extension("tmp");
+ fs::write(&tmp, serde_json::to_vec(saved).map_err(io::Error::other)?)?;
+ fs::rename(&tmp, file)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn it_is_kept_and_a_broken_file_reads_as_off() {
+ let dir = tempfile::tempdir().expect("dir");
+ let file = dir.path().join("autobackup.json");
+ assert_eq!(load(&file), Saved::default());
+ let saved = Saved {
+ enabled: true,
+ every: AutoBackupEvery::Weekly,
+ last_ok_at: Some(5),
+ fingerprint: Some("ab".to_owned()),
+ next_at: Some(9),
+ last_error: Some("offline".to_owned()),
+ };
+ save(&file, &saved).expect("save");
+ assert_eq!(load(&file), saved);
+ fs::write(&file, "{not json").expect("write");
+ assert!(!load(&file).enabled);
+ }
+
+ #[test]
+ fn weekly_is_seven_days() {
+ assert_eq!(period(AutoBackupEvery::Daily).as_secs(), 86_400);
+ assert_eq!(period(AutoBackupEvery::Weekly).as_secs(), 7 * 86_400);
+ }
+}
diff --git a/crates/botloftd/src/backup/export.rs b/crates/botloftd/src/backup/export.rs
index 4e49a062..c40b2f70 100644
--- a/crates/botloftd/src/backup/export.rs
+++ b/crates/botloftd/src/backup/export.rs
@@ -168,13 +168,13 @@ fn export_to(
}
/// What a backup of this scope takes of the folders.
-struct Filter<'a> {
- scope: BackupScope,
- bots: &'a HashSet,
+pub(super) struct Filter<'a> {
+ pub(super) scope: BackupScope,
+ pub(super) bots: &'a HashSet,
}
impl Filter<'_> {
- fn takes(&self, relative: &Path, is_dir: bool) -> bool {
+ pub(super) fn takes(&self, relative: &Path, is_dir: bool) -> bool {
if regenerated(relative) {
return false;
}
diff --git a/crates/botloftd/src/backup/fingerprint.rs b/crates/botloftd/src/backup/fingerprint.rs
new file mode 100644
index 00000000..1e245c16
--- /dev/null
+++ b/crates/botloftd/src/backup/fingerprint.rs
@@ -0,0 +1,90 @@
+//! A hash of what the light copy would hold now (spec 27.10), so the
+//! automatic backup sends a copy only after something the owner made changed:
+//! the structure and the routines (`Store::light_fingerprint`) and each bot's
+//! memory and rules, taken by the same filter as the copy itself.
+
+use std::collections::HashSet;
+use std::fs::{self, File};
+use std::io::{self, Read};
+use std::path::{Path, PathBuf};
+
+use botloft_core::protocol::BackupScope;
+use sha2::{Digest, Sha256};
+
+use super::export::Filter;
+use crate::state::Daemon;
+
+/// The file the database copy is made in; it is gone when the hash is.
+const SCRATCH: &str = "cloud-fingerprint.db";
+
+pub fn light_fingerprint(daemon: &Daemon) -> io::Result {
+ let other = |err: botloft_store::StoreError| io::Error::other(err.to_string());
+ let (database, crews, bots) = {
+ let store = daemon.store();
+ let database = store
+ .light_fingerprint(&daemon.paths.home.join(SCRATCH))
+ .map_err(other)?;
+ let crews = store.crews(true).map_err(other)?;
+ let mut bots = HashSet::new();
+ for crew in &crews {
+ for bot in store.bots(Some(&crew.id), true).map_err(other)? {
+ bots.insert(format!("{}/{}", crew.slug, bot.slug).to_ascii_lowercase());
+ }
+ }
+ (database, crews, bots)
+ };
+ let mut hash = Sha256::new();
+ hash.update(database.as_bytes());
+ let filter = Filter {
+ scope: BackupScope::Light,
+ bots: &bots,
+ };
+ let mut slugs: Vec<_> = crews.iter().map(|crew| crew.slug.as_str()).collect();
+ slugs.sort_unstable();
+ for slug in slugs {
+ walk(
+ &daemon.paths.crew_dir(slug),
+ &PathBuf::from(slug),
+ &filter,
+ &mut hash,
+ )?;
+ }
+ Ok(hash
+ .finalize()
+ .iter()
+ .map(|byte| format!("{byte:02x}"))
+ .collect())
+}
+
+/// Every file the filter takes under `folder`, by name, into `hash`.
+fn walk(folder: &Path, inside: &Path, filter: &Filter<'_>, hash: &mut Sha256) -> io::Result<()> {
+ let Ok(entries) = fs::read_dir(folder) else {
+ return Ok(());
+ };
+ let mut entries: Vec<_> = entries.flatten().collect();
+ entries.sort_by_key(|entry| entry.file_name());
+ for entry in entries {
+ let kind = entry.file_type()?;
+ let relative = inside.join(entry.file_name());
+ if kind.is_symlink() || !filter.takes(&relative, kind.is_dir()) {
+ continue;
+ }
+ if kind.is_dir() {
+ walk(&entry.path(), &relative, filter, hash)?;
+ } else if kind.is_file() {
+ hash.update(relative.to_string_lossy().replace('\\', "/").as_bytes());
+ hash.update([0]);
+ let mut file = File::open(entry.path())?;
+ let mut buffer = [0u8; 64 * 1024];
+ loop {
+ let read = file.read(&mut buffer)?;
+ if read == 0 {
+ break;
+ }
+ hash.update(&buffer[..read]);
+ }
+ hash.update([0xFF]);
+ }
+ }
+ Ok(())
+}
diff --git a/crates/botloftd/src/backup/mod.rs b/crates/botloftd/src/backup/mod.rs
index 0818da0b..768ba940 100644
--- a/crates/botloftd/src/backup/mod.rs
+++ b/crates/botloftd/src/backup/mod.rs
@@ -2,6 +2,7 @@
//! crew's folder, in one file sealed with a passphrase only the owner has.
pub mod export;
+pub mod fingerprint;
pub mod restore;
pub mod seal;
diff --git a/crates/botloftd/src/cloud/mod.rs b/crates/botloftd/src/cloud/mod.rs
index 979302ce..2b7c01a0 100644
--- a/crates/botloftd/src/cloud/mod.rs
+++ b/crates/botloftd/src/cloud/mod.rs
@@ -26,6 +26,8 @@ pub struct CloudSettings {
pub url: String,
/// How often a waiting sign-in asks whether the link was opened.
pub poll: Duration,
+ /// How often the automatic backup looks whether its time has come.
+ pub auto_tick: Duration,
}
impl Default for CloudSettings {
@@ -33,6 +35,7 @@ impl Default for CloudSettings {
Self {
url: String::new(),
poll: Duration::from_secs(2),
+ auto_tick: crate::autobackup::DEFAULT_TICK,
}
}
}
@@ -42,6 +45,7 @@ impl CloudSettings {
Self {
url: config.cloud.url.trim().to_owned(),
poll: Duration::from_millis(config.cloud.poll_ms),
+ auto_tick: crate::autobackup::DEFAULT_TICK,
}
}
}
@@ -118,6 +122,9 @@ impl std::fmt::Display for CloudError {
pub struct Cloud {
pub(crate) settings: CloudSettings,
pending: Arc>>,
+ /// One copy goes up at a time: a manual one and an automatic one share
+ /// the folder the sealed file is made in.
+ sending: Mutex<()>,
}
impl Cloud {
@@ -125,6 +132,7 @@ impl Cloud {
Self {
settings,
pending: Arc::default(),
+ sending: Mutex::new(()),
}
}
@@ -146,6 +154,13 @@ impl Cloud {
creds::remove(secrets_dir);
}
+ /// Held while a copy is made and sent.
+ pub(crate) fn sending(&self) -> std::sync::MutexGuard<'_, ()> {
+ self.sending
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
/// A sign-in waits for the link.
pub fn is_pending(&self) -> bool {
self.pending
diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs
index b58e9edb..a18a9061 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 approvals;
+pub mod autobackup;
pub mod autostart;
pub mod backup;
pub mod browser;
diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs
index be8f114d..7f6318f0 100644
--- a/crates/botloftd/src/main.rs
+++ b/crates/botloftd/src/main.rs
@@ -127,6 +127,7 @@ fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> a
.with_context(|| format!("cannot create {}", paths.workspaces_root.display()))?;
info!(home = %paths.home.display(), workspaces = %paths.workspaces_root.display(), "starting");
+ let secrets = botloftd::autobackup::SystemSecrets::for_home(&paths.home);
let daemon = Daemon::new(DaemonOptions {
paths,
port: config.port,
@@ -143,6 +144,7 @@ fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> a
trash: Arc::new(RecycleBin),
owner_idle: Arc::new(platform::desktop::owner_idle),
cloud: botloftd::cloud::CloudSettings::from_config(&config),
+ secrets: Arc::new(secrets),
});
// No bot process survived the last run, so nobody waits for these.
approvals::expire_all(&daemon);
@@ -169,6 +171,7 @@ fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> a
tokio::spawn(supervisor::run(Arc::clone(&daemon)));
tokio::spawn(courier::run(Arc::clone(&daemon)));
tokio::spawn(routines::run(Arc::clone(&daemon)));
+ tokio::spawn(botloftd::autobackup::run(Arc::clone(&daemon)));
tokio::spawn(browser::run(Arc::clone(&daemon)));
// The outline around the window a bot uses (spec 24.9).
tokio::spawn(botloftd::desktop::outline::run(Arc::clone(&daemon)));
diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs
index efa1344e..1ad642ef 100644
--- a/crates/botloftd/src/platform/mod.rs
+++ b/crates/botloftd/src/platform/mod.rs
@@ -14,14 +14,14 @@ use std::path::{Path, PathBuf};
#[cfg(unix)]
pub use unix::{
KeepAwake, ProcessJob, TASK_KIND, delete_task, find_task, leave_own_console, owner_name,
- recycle, register_task, restrict_to_current_user, run_task, sign_in_id, stop_task,
- track_groups, user_environment,
+ recycle, register_task, restrict_to_current_user, run_task, secret_available, secret_delete,
+ secret_load, secret_save, sign_in_id, stop_task, track_groups, user_environment,
};
#[cfg(windows)]
pub use windows::{
KeepAwake, ProcessJob, TASK_KIND, delete_task, find_task, leave_own_console, owner_name,
- recycle, register_task, restrict_to_current_user, run_task, sign_in_id, stop_task,
- track_groups, user_environment,
+ recycle, register_task, restrict_to_current_user, run_task, secret_available, secret_delete,
+ secret_load, secret_save, sign_in_id, stop_task, track_groups, user_environment,
};
/// A program the system starts for the owner (spec 14).
diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs
index 1efb9927..3d2c7027 100644
--- a/crates/botloftd/src/platform/unix.rs
+++ b/crates/botloftd/src/platform/unix.rs
@@ -2,6 +2,7 @@
//! started for the owner by systemd or launchd.
mod account;
+mod credentials;
mod env;
mod job;
// Both build everywhere, so each one's tests run on either system.
@@ -19,6 +20,7 @@ use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
pub use self::account::owner_name;
+pub use self::credentials::{secret_available, secret_delete, secret_load, secret_save};
pub use self::env::user_environment;
pub use self::job::ProcessJob;
#[cfg(target_os = "macos")]
diff --git a/crates/botloftd/src/platform/unix/credentials.rs b/crates/botloftd/src/platform/unix/credentials.rs
new file mode 100644
index 00000000..4b1972a8
--- /dev/null
+++ b/crates/botloftd/src/platform/unix/credentials.rs
@@ -0,0 +1,27 @@
+//! No credential store on Linux and macOS yet (spec 27.10): the automatic
+//! backup needs one to keep its passphrase, so it stays off there.
+
+use std::io;
+
+pub fn secret_available() -> bool {
+ false
+}
+
+fn unsupported() -> io::Error {
+ io::Error::new(
+ io::ErrorKind::Unsupported,
+ "there is no credential store on this system",
+ )
+}
+
+pub fn secret_save(_target: &str, _secret: &str) -> io::Result<()> {
+ Err(unsupported())
+}
+
+pub fn secret_load(_target: &str) -> io::Result> {
+ Ok(None)
+}
+
+pub fn secret_delete(_target: &str) -> io::Result<()> {
+ Ok(())
+}
diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs
index 981373b6..d54e8371 100644
--- a/crates/botloftd/src/platform/windows.rs
+++ b/crates/botloftd/src/platform/windows.rs
@@ -2,6 +2,7 @@
//! console signals.
mod console;
+mod credentials;
mod env;
mod job;
mod owner;
@@ -12,6 +13,7 @@ mod task;
mod task_xml;
pub use console::leave_own_console;
+pub use credentials::{secret_available, secret_delete, secret_load, secret_save};
pub use env::user_environment;
pub use job::ProcessJob;
pub use owner::owner_name;
diff --git a/crates/botloftd/src/platform/windows/credentials.rs b/crates/botloftd/src/platform/windows/credentials.rs
new file mode 100644
index 00000000..19157c00
--- /dev/null
+++ b/crates/botloftd/src/platform/windows/credentials.rs
@@ -0,0 +1,111 @@
+//! A secret in the Windows Credential Manager, for the current user only
+//! (spec 27.10): the passphrase the automatic backup seals with.
+
+use std::io;
+use std::ptr;
+
+use windows::Win32::Foundation::ERROR_NOT_FOUND;
+use windows::Win32::Security::Credentials::{
+ CRED_PERSIST_LOCAL_MACHINE, CRED_TYPE_GENERIC, CREDENTIALW, CredDeleteW, CredFree, CredReadW,
+ CredWriteW,
+};
+use windows::core::{HSTRING, PWSTR};
+
+/// The blob of a generic credential holds at most 5 x 512 bytes.
+const MAX_SECRET: usize = 2560;
+
+pub fn secret_available() -> bool {
+ true
+}
+
+pub fn secret_save(target: &str, secret: &str) -> io::Result<()> {
+ if secret.len() > MAX_SECRET {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "the secret is too long for the credential store",
+ ));
+ }
+ let name = HSTRING::from(target);
+ let user = HSTRING::from("Botloft");
+ let mut blob = secret.as_bytes().to_vec();
+ let credential = CREDENTIALW {
+ Type: CRED_TYPE_GENERIC,
+ TargetName: PWSTR(name.as_ptr().cast_mut()),
+ CredentialBlobSize: u32::try_from(blob.len()).unwrap_or(0),
+ CredentialBlob: blob.as_mut_ptr(),
+ Persist: CRED_PERSIST_LOCAL_MACHINE,
+ UserName: PWSTR(user.as_ptr().cast_mut()),
+ ..Default::default()
+ };
+ // SAFETY: `credential` points at `name`, `user` and `blob`, which all
+ // outlive the call; the system copies what it needs.
+ let written = unsafe { CredWriteW(&credential, 0) };
+ // The passphrase does not stay in this memory longer than it must.
+ blob.fill(0);
+ written.map_err(|err| io::Error::other(err.to_string()))
+}
+
+pub fn secret_load(target: &str) -> io::Result > {
+ let name = HSTRING::from(target);
+ let mut found: *mut CREDENTIALW = ptr::null_mut();
+ // SAFETY: `found` receives a buffer the system owns until `CredFree`.
+ let read = unsafe { CredReadW(&name, CRED_TYPE_GENERIC, None, &mut found) };
+ match read {
+ Ok(()) => {}
+ Err(err) if err.code() == ERROR_NOT_FOUND.to_hresult() => return Ok(None),
+ Err(err) => return Err(io::Error::other(err.to_string())),
+ }
+ // SAFETY: a successful read left `found` pointing at a credential whose
+ // blob is `CredentialBlobSize` bytes; the copy is made before the free.
+ let secret = unsafe {
+ let credential = &*found;
+ let bytes = if credential.CredentialBlob.is_null() {
+ &[][..]
+ } else {
+ std::slice::from_raw_parts(
+ credential.CredentialBlob,
+ credential.CredentialBlobSize as usize,
+ )
+ };
+ let secret = String::from_utf8(bytes.to_vec()).ok();
+ CredFree(found.cast());
+ secret
+ };
+ Ok(secret)
+}
+
+pub fn secret_delete(target: &str) -> io::Result<()> {
+ let name = HSTRING::from(target);
+ // SAFETY: plain call with a valid, NUL-ended name.
+ match unsafe { CredDeleteW(&name, CRED_TYPE_GENERIC, None) } {
+ Ok(()) => Ok(()),
+ Err(err) if err.code() == ERROR_NOT_FOUND.to_hresult() => Ok(()),
+ Err(err) => Err(io::Error::other(err.to_string())),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_secret_is_kept_read_back_and_removed() {
+ // A name of its own, so a real install's secret is never touched.
+ let target = format!("Botloft test {}", std::process::id());
+ assert_eq!(secret_load(&target).expect("empty"), None);
+ secret_save(&target, "a long passphrase é ü").expect("save");
+ assert_eq!(
+ secret_load(&target).expect("load").as_deref(),
+ Some("a long passphrase é ü")
+ );
+ secret_save(&target, "another one").expect("overwrite");
+ assert_eq!(
+ secret_load(&target).expect("again").as_deref(),
+ Some("another one")
+ );
+ secret_delete(&target).expect("delete");
+ assert_eq!(secret_load(&target).expect("gone"), None);
+ // Deleting what is not there is fine.
+ secret_delete(&target).expect("delete twice");
+ }
+}
diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs
index 79b5cf84..d2820426 100644
--- a/crates/botloftd/src/rpc/dispatch.rs
+++ b/crates/botloftd/src/rpc/dispatch.rs
@@ -8,9 +8,9 @@ use serde_json::Value;
use super::jsonrpc::{RpcError, empty_params};
use crate::approvals;
use crate::service::{
- self, ApiResult, archive, attachments, backup, bots, catalog, chat, cloud, crew_access, crews,
- delete, deliveries, desktop, files, lead, mcp, messages, models, modes, questions, reactions,
- routines, rules, screens, settings, tasks, usage,
+ self, ApiError, ApiResult, archive, attachments, autobackup, backup, bots, catalog, chat,
+ cloud, crew_access, crews, delete, deliveries, desktop, files, lead, mcp, messages, models,
+ modes, questions, reactions, routines, rules, screens, settings, tasks, usage,
};
use crate::state::Daemon;
@@ -76,6 +76,10 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(cloud::download(daemon, parse(params)?)),
method::CLOUD_DELETE => reply(cloud::delete(daemon, parse(params)?).map(|()| Value::Null)),
method::CLOUD_DELETE_ACCOUNT => reply(cloud::delete_account(daemon).map(|()| Value::Null)),
+ method::AUTOBACKUP_STATUS => reply(Ok::<_, ApiError>(autobackup::status(daemon))),
+ method::AUTOBACKUP_ENABLE => reply(autobackup::enable(daemon, parse(params)?)),
+ method::AUTOBACKUP_SET_EVERY => reply(autobackup::set_every(daemon, parse(params)?)),
+ method::AUTOBACKUP_DISABLE => reply(Ok::<_, ApiError>(autobackup::disable(daemon))),
method::CREW_ACCESS_LIST => reply(crew_access::list(daemon, parse(params)?)),
method::CREW_ACCESS_REVOKE => reply(crew_access::revoke(daemon, parse(params)?)),
method::MCP_SERVERS => reply(mcp::overview(daemon)),
diff --git a/crates/botloftd/src/rpc/notify.rs b/crates/botloftd/src/rpc/notify.rs
index db85d6bd..473a5c53 100644
--- a/crates/botloftd/src/rpc/notify.rs
+++ b/crates/botloftd/src/rpc/notify.rs
@@ -36,5 +36,6 @@ pub(super) fn to_notification(event: &Event) -> String {
Event::CloudSignedIn(signed) => note(notification::CLOUD_SIGNED_IN, signed),
Event::CloudProgress(progress) => note(notification::CLOUD_PROGRESS, progress),
Event::CloudSigninExpired => note(notification::CLOUD_SIGNIN_EXPIRED, &()),
+ Event::AutoBackupChanged(status) => note(notification::AUTOBACKUP_CHANGED, status),
}
}
diff --git a/crates/botloftd/src/service/autobackup.rs b/crates/botloftd/src/service/autobackup.rs
new file mode 100644
index 00000000..c2fe5fe2
--- /dev/null
+++ b/crates/botloftd/src/service/autobackup.rs
@@ -0,0 +1,75 @@
+//! `autobackup.*` (spec 27.10): turning the automatic backup on and off. The
+//! sending itself is `crate::autobackup`.
+
+use botloft_core::protocol::{AutoBackupEnableParams, AutoBackupEveryParams, AutoBackupStatus};
+
+use super::{ApiError, ApiResult, backup, cloud};
+use crate::autobackup::Saved;
+use crate::backup::seal::{PASSPHRASE_MIN, SealError};
+use crate::state::{Daemon, Event};
+
+fn rule(reason: &'static str, message: &str) -> ApiError {
+ ApiError::Rule {
+ reason,
+ message: message.to_owned(),
+ }
+}
+
+fn changed(daemon: &Daemon) -> AutoBackupStatus {
+ let status = daemon.autobackup.status();
+ daemon.emit(Event::AutoBackupChanged(status.clone()));
+ status
+}
+
+/// `autobackup.status`
+pub fn status(daemon: &Daemon) -> AutoBackupStatus {
+ daemon.autobackup.status()
+}
+
+/// `autobackup.enable`: keeps the passphrase in the credential store and
+/// looks at once.
+pub fn enable(daemon: &Daemon, params: AutoBackupEnableParams) -> ApiResult {
+ if !daemon.autobackup.secrets().available() {
+ return Err(rule(
+ "no_keystore",
+ "this computer has nowhere safe to keep the passphrase",
+ ));
+ }
+ if params.passphrase.chars().count() < PASSPHRASE_MIN {
+ return Err(backup::api(SealError::ShortPassphrase));
+ }
+ // The copies go to the account, so it has to be there.
+ cloud::signed_in(daemon)?;
+ daemon
+ .autobackup
+ .secrets()
+ .save(¶ms.passphrase)
+ .map_err(|_| rule("no_keystore", "the passphrase could not be kept safely"))?;
+ daemon.autobackup.update(|saved| {
+ *saved = Saved {
+ enabled: true,
+ every: params.every,
+ last_ok_at: saved.last_ok_at,
+ ..Saved::default()
+ };
+ });
+ daemon.autobackup.wake();
+ Ok(changed(daemon))
+}
+
+/// `autobackup.set_every`
+pub fn set_every(daemon: &Daemon, params: AutoBackupEveryParams) -> ApiResult {
+ daemon.autobackup.update(|saved| {
+ saved.every = params.every;
+ // Looks again soon, finds nothing new if so, and counts the new period.
+ saved.next_at = None;
+ });
+ daemon.autobackup.wake();
+ Ok(changed(daemon))
+}
+
+/// `autobackup.disable`
+pub fn disable(daemon: &Daemon) -> AutoBackupStatus {
+ daemon.autobackup.turn_off();
+ changed(daemon)
+}
diff --git a/crates/botloftd/src/service/cloud.rs b/crates/botloftd/src/service/cloud.rs
index 82bd307e..63ddf009 100644
--- a/crates/botloftd/src/service/cloud.rs
+++ b/crates/botloftd/src/service/cloud.rs
@@ -13,6 +13,7 @@ use tracing::info;
use super::{ApiError, ApiResult, backup};
use crate::backup::export;
+use crate::backup::fingerprint::light_fingerprint;
use crate::cloud::signin::Signin;
use crate::cloud::{self, CloudError, Credentials, Progress, Server};
use crate::state::{Daemon, Event};
@@ -40,7 +41,7 @@ fn fail(daemon: &Daemon, err: CloudError) -> ApiError {
}
}
-fn signed_in(daemon: &Daemon) -> ApiResult<(Server, Credentials)> {
+pub(super) fn signed_in(daemon: &Daemon) -> ApiResult<(Server, Credentials)> {
let server = daemon.cloud.server().map_err(|err| fail(daemon, err))?;
let credentials = daemon
.cloud
@@ -131,14 +132,37 @@ pub fn signout(daemon: &Daemon) {
let _ = run(server.logout(&credentials.token));
}
daemon.cloud.forget(&daemon.paths.secrets());
+ // Without the account there is nowhere to send the copies.
+ daemon.autobackup.turn_off();
+ daemon.emit(Event::AutoBackupChanged(daemon.autobackup.status()));
}
/// `cloud.upload`: a light copy sealed with the passphrase, sent as the
/// account's newest.
pub fn upload(daemon: &Daemon, params: CloudUploadParams) -> ApiResult {
+ // What the copy holds, taken first: the automatic backup then knows this
+ // one covers it and does not send the same thing again soon.
+ let covers = daemon
+ .autobackup
+ .snapshot()
+ .enabled
+ .then(|| light_fingerprint(daemon).ok())
+ .flatten();
+ let copy = send_light(daemon, ¶ms.passphrase)?;
+ if let Some(fingerprint) = covers {
+ daemon
+ .autobackup
+ .covered(fingerprint, daemon.clock.now_ms());
+ }
+ Ok(copy)
+}
+
+/// Makes a light copy sealed with `passphrase` and sends it, one at a time.
+/// The owner's button and the automatic backup both come here.
+pub(crate) fn send_light(daemon: &Daemon, passphrase: &str) -> ApiResult {
let (server, credentials) = signed_in(daemon)?;
- let exported =
- export::export(daemon, ¶ms.passphrase, BackupScope::Light).map_err(backup::api)?;
+ let _sending = daemon.cloud.sending();
+ let exported = export::export(daemon, passphrase, BackupScope::Light).map_err(backup::api)?;
let sent = run(cloud::upload(
&server,
&credentials.token,
diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs
index 0ec1b451..237c225e 100644
--- a/crates/botloftd/src/service/mod.rs
+++ b/crates/botloftd/src/service/mod.rs
@@ -3,6 +3,7 @@
pub mod archive;
pub mod attachments;
+pub mod autobackup;
pub mod backup;
pub mod bots;
pub mod catalog;
diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs
index e2a064ed..5ff4c813 100644
--- a/crates/botloftd/src/state.rs
+++ b/crates/botloftd/src/state.rs
@@ -6,10 +6,11 @@ use std::sync::{Arc, Mutex, MutexGuard, TryLockError};
use std::time::Instant;
use botloft_core::protocol::{
- AccountUsage, Bot, BotContextChanged, BotDeleted, BotDesktop, BotMcp, BotRules,
- BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, CloudProgress,
- CloudSignedIn, Crew, CrewDeleted, Delivery, DesktopAwayUse, DesktopState, FolderRecycled,
- McpOverview, Message, Question, ReactionChanged, Routine, RoutineRun, ScreenDraft, Task,
+ AccountUsage, AutoBackupStatus, Bot, BotContextChanged, BotDeleted, BotDesktop, BotMcp,
+ BotRules, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged,
+ CloudProgress, CloudSignedIn, Crew, CrewDeleted, Delivery, DesktopAwayUse, DesktopState,
+ FolderRecycled, McpOverview, Message, Question, ReactionChanged, Routine, RoutineRun,
+ ScreenDraft, Task,
};
use botloft_store::Store;
use tokio::runtime::{Handle, RuntimeFlavor};
@@ -63,6 +64,7 @@ pub enum Event {
CloudSignedIn(CloudSignedIn),
CloudProgress(CloudProgress),
CloudSigninExpired,
+ AutoBackupChanged(AutoBackupStatus),
}
/// Events buffered per connection before a slow client is dropped.
@@ -108,6 +110,8 @@ pub struct DaemonOptions {
pub owner_idle: OwnerIdle,
/// The server of the account (spec 6, `[cloud]`).
pub cloud: crate::cloud::CloudSettings,
+ /// Where the automatic backup keeps its passphrase (spec 27.10).
+ pub secrets: Arc,
}
pub struct Daemon {
@@ -125,6 +129,8 @@ pub struct Daemon {
pub desktop: Desktop,
/// The account and the copies in the cloud (spec 27).
pub cloud: crate::cloud::Cloud,
+ /// The copies it sends by itself (spec 27.10).
+ pub autobackup: crate::autobackup::AutoBackup,
/// Other crews a bot may reach for its current turn (spec 10.4).
pub crew_access: crate::service::crew_access::TurnAccess,
/// What each bot's process had cost by its last turn (spec 8.7).
@@ -169,6 +175,11 @@ impl Daemon {
),
screens: Screens::default(),
desktop: Desktop::new(options.owner_idle),
+ autobackup: crate::autobackup::AutoBackup::new(
+ &options.paths.home,
+ options.secrets,
+ options.cloud.auto_tick,
+ ),
cloud: crate::cloud::Cloud::new(options.cloud),
crew_access: Default::default(),
costs: Default::default(),
diff --git a/crates/botloftd/tests/autobackup.rs b/crates/botloftd/tests/autobackup.rs
new file mode 100644
index 00000000..e465c417
--- /dev/null
+++ b/crates/botloftd/tests/autobackup.rs
@@ -0,0 +1,304 @@
+//! The automatic backup (spec 27.10), against the real account server running
+//! in this process. The daemon's clock is moved by hand; `tick` is one pass of
+//! the loop that runs in the real daemon.
+
+mod common;
+
+use std::fs;
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use botloft_core::protocol::BackupScope;
+use botloftd::autobackup::{MemorySecrets, tick};
+use botloftd::backup::restore;
+use botloftd::clock::Clock;
+use botloftd::paths::Paths;
+use common::cloud_server::{cloud_server, sign_in, signed_in};
+use common::{Client, TestDaemon};
+use serde_json::{Value, json};
+
+const PASSPHRASE: &str = "correct horse battery";
+const DAY: Duration = Duration::from_secs(24 * 3600);
+const HOUR: Duration = Duration::from_secs(3600);
+
+/// One pass of the loop, on the blocking pool as in the daemon.
+async fn look(t: &TestDaemon) {
+ let daemon = Arc::clone(&t.daemon);
+ tokio::task::spawn_blocking(move || tick(&daemon))
+ .await
+ .expect("a pass");
+}
+
+async fn copies(app: &mut Client) -> Vec {
+ app.call("cloud.copies", Value::Null).await.expect("copies")["copies"]
+ .as_array()
+ .expect("a list")
+ .clone()
+}
+
+async fn status(app: &mut Client) -> Value {
+ app.call("autobackup.status", Value::Null)
+ .await
+ .expect("status")
+}
+
+async fn turn_on(app: &mut Client, every: &str) -> Value {
+ app.call(
+ "autobackup.enable",
+ json!({ "passphrase": PASSPHRASE, "every": every }),
+ )
+ .await
+ .expect("enable")
+}
+
+/// A crew with a bot whose memory the copy carries; returns the memory file.
+async fn a_bot_with_memory(t: &TestDaemon, app: &mut Client) -> PathBuf {
+ let crew = app
+ .call("crews.create", json!({ "name": "Ops" }))
+ .await
+ .expect("crew");
+ let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "Finds", "instructions": "" });
+ app.call("bots.create", bot).await.expect("bot");
+ let memory = t.paths.bot_workspace("ops", "scout").join("CLAUDE.md");
+ fs::write(&memory, "The client likes short reports.").expect("memory");
+ memory
+}
+
+fn ms(by: Duration) -> i64 {
+ i64::try_from(by.as_millis()).expect("fits")
+}
+
+#[tokio::test]
+async fn a_daily_copy_goes_up_when_something_changed_and_not_before() {
+ let cloud = cloud_server().await;
+ let (t, mut app) = signed_in(&cloud).await;
+ let memory = a_bot_with_memory(&t, &mut app).await;
+
+ let off = status(&mut app).await;
+ assert_eq!(off["available"], true);
+ assert_eq!(off["enabled"], false);
+
+ // Turning it on looks at once: the first copy goes up.
+ let on = turn_on(&mut app, "daily").await;
+ assert_eq!(
+ (on["enabled"].clone(), on["every"].clone()),
+ (true.into(), "daily".into())
+ );
+ look(&t).await;
+ assert_eq!(copies(&mut app).await.len(), 1);
+ let sent_at = t.clock.now_ms();
+ let after = status(&mut app).await;
+ assert_eq!(after["lastOkAt"], sent_at);
+ assert_eq!(after["nextAt"], sent_at + ms(DAY));
+ assert!(after["lastError"].is_null());
+
+ // Not its time yet: nothing happens, even with a change.
+ fs::write(&memory, "The client likes long reports.").expect("changed");
+ look(&t).await;
+ assert_eq!(copies(&mut app).await.len(), 1);
+
+ // Its time, and what changed goes up.
+ t.clock.advance(DAY + HOUR);
+ look(&t).await;
+ assert_eq!(copies(&mut app).await.len(), 2);
+
+ // Its time again and nothing changed: it looks and sends nothing.
+ t.clock.advance(DAY + HOUR);
+ look(&t).await;
+ assert_eq!(copies(&mut app).await.len(), 2);
+ let quiet = status(&mut app).await;
+ assert_eq!(quiet["nextAt"], t.clock.now_ms() + ms(DAY));
+
+ // The copy is sealed with the passphrase it keeps, and it is the light one.
+ let newest = copies(&mut app).await[0]["id"].clone();
+ let downloaded = app
+ .call("cloud.download", json!({ "id": newest }))
+ .await
+ .expect("download");
+ let file = PathBuf::from(downloaded["path"].as_str().expect("path"));
+ let elsewhere = tempfile::tempdir().expect("dir");
+ let paths = Paths::new(elsewhere.path().join("home"), elsewhere.path().join("work"));
+ assert!(restore::stage(&paths, &file, "not the passphrase").is_err());
+ let manifest = restore::stage(&paths, &file, PASSPHRASE).expect("stage");
+ assert_eq!(manifest.scope, BackupScope::Light);
+}
+
+#[tokio::test]
+async fn a_weekly_copy_waits_a_week() {
+ let cloud = cloud_server().await;
+ let (t, mut app) = signed_in(&cloud).await;
+ let memory = a_bot_with_memory(&t, &mut app).await;
+ turn_on(&mut app, "weekly").await;
+ look(&t).await;
+ assert_eq!(copies(&mut app).await.len(), 1);
+
+ fs::write(&memory, "Changed.").expect("changed");
+ t.clock.advance(DAY * 3);
+ look(&t).await;
+ assert_eq!(copies(&mut app).await.len(), 1);
+ t.clock.advance(DAY * 5);
+ look(&t).await;
+ assert_eq!(copies(&mut app).await.len(), 2);
+}
+
+#[tokio::test]
+async fn what_changes_by_itself_does_not_make_a_copy() {
+ let cloud = cloud_server().await;
+ let (t, mut app) = signed_in(&cloud).await;
+ a_bot_with_memory(&t, &mut app).await;
+ turn_on(&mut app, "daily").await;
+ look(&t).await;
+ assert_eq!(copies(&mut app).await.len(), 1);
+
+ // A conversation is not worth a copy.
+ let bots = app.call("bots.list", Value::Null).await.expect("bots");
+ let bot = bots[0]["id"].clone();
+ app.call("messages.send", json!({ "botId": bot, "body": "hello" }))
+ .await
+ .expect("send");
+ t.clock.advance(DAY + HOUR);
+ look(&t).await;
+ assert_eq!(copies(&mut app).await.len(), 1);
+}
+
+#[tokio::test]
+async fn a_copy_sent_by_hand_counts_for_the_automatic_one() {
+ let cloud = cloud_server().await;
+ let (t, mut app) = signed_in(&cloud).await;
+ a_bot_with_memory(&t, &mut app).await;
+ turn_on(&mut app, "daily").await;
+ // The owner is quicker than the loop.
+ app.call("cloud.upload", json!({ "passphrase": PASSPHRASE }))
+ .await
+ .expect("upload");
+ look(&t).await;
+ assert_eq!(copies(&mut app).await.len(), 1);
+ // And the next look is a day away.
+ let state = status(&mut app).await;
+ assert_eq!(state["nextAt"], t.clock.now_ms() + ms(DAY));
+}
+
+#[tokio::test]
+async fn turning_it_on_asks_for_what_it_needs() {
+ let cloud = cloud_server().await;
+ let t = TestDaemon::start_with_cloud(&cloud.url).await;
+ let mut app = t.session().await;
+ let enable = |passphrase: &str| json!({ "passphrase": passphrase, "every": "daily" });
+
+ let no_account = app
+ .call("autobackup.enable", enable(PASSPHRASE))
+ .await
+ .expect_err("no account");
+ assert_eq!(no_account.reason, "not_signed_in");
+
+ sign_in(&cloud, &mut app).await;
+ let short = app
+ .call("autobackup.enable", enable("short"))
+ .await
+ .expect_err("short");
+ assert_eq!(short.reason, "short_passphrase");
+ assert_eq!(status(&mut app).await["enabled"], false);
+ assert_eq!(t.daemon.autobackup.secrets().load().expect("load"), None);
+}
+
+#[tokio::test]
+async fn without_a_credential_store_it_cannot_be_turned_on() {
+ let cloud = cloud_server().await;
+ let t =
+ TestDaemon::start_with_secrets(&cloud.url, Arc::new(MemorySecrets::unavailable())).await;
+ let mut app = t.session().await;
+ sign_in(&cloud, &mut app).await;
+ assert_eq!(status(&mut app).await["available"], false);
+ let refused = app
+ .call(
+ "autobackup.enable",
+ json!({ "passphrase": PASSPHRASE, "every": "daily" }),
+ )
+ .await
+ .expect_err("no store");
+ assert_eq!(refused.reason, "no_keystore");
+}
+
+#[tokio::test]
+async fn it_stops_when_it_is_turned_off_the_passphrase_is_gone_or_the_account_is_left() {
+ let cloud = cloud_server().await;
+ let (t, mut app) = signed_in(&cloud).await;
+ a_bot_with_memory(&t, &mut app).await;
+ let kept = || t.daemon.autobackup.secrets().load().expect("load");
+
+ // Turning it off by hand forgets the passphrase.
+ turn_on(&mut app, "daily").await;
+ assert_eq!(kept().as_deref(), Some(PASSPHRASE));
+ let off = app
+ .call("autobackup.disable", Value::Null)
+ .await
+ .expect("disable");
+ assert_eq!(off["enabled"], false);
+ assert_eq!(kept(), None);
+
+ // The passphrase disappears from the credential store.
+ turn_on(&mut app, "daily").await;
+ t.daemon.autobackup.secrets().delete().expect("delete");
+ look(&t).await;
+ let stopped = status(&mut app).await;
+ assert_eq!(stopped["enabled"], false);
+ assert_eq!(stopped["lastError"], "no_passphrase");
+ assert!(copies(&mut app).await.is_empty());
+
+ // Signing out forgets the passphrase and turns it off.
+ turn_on(&mut app, "daily").await;
+ app.call("cloud.signout", Value::Null)
+ .await
+ .expect("signout");
+ assert_eq!(status(&mut app).await["enabled"], false);
+ assert_eq!(kept(), None);
+}
+
+#[tokio::test]
+async fn the_apps_are_told_when_it_changes_and_what_it_saves_has_no_passphrase() {
+ let cloud = cloud_server().await;
+ let (t, mut app) = signed_in(&cloud).await;
+ turn_on(&mut app, "weekly").await;
+ let told = app.notification("autobackup.changed").await;
+ assert_eq!(told["enabled"], true);
+ assert_eq!(told["every"], "weekly");
+
+ app.call("autobackup.set_every", json!({ "every": "daily" }))
+ .await
+ .expect("set");
+ assert_eq!(status(&mut app).await["every"], "daily");
+
+ // What it remembers is in the data folder, and the passphrase is not.
+ let saved = fs::read_to_string(t.paths.home.join("autobackup.json")).expect("saved");
+ assert!(saved.contains("\"enabled\":true") && saved.contains("daily"));
+ assert!(!saved.contains(PASSPHRASE));
+}
+
+#[tokio::test]
+async fn a_try_without_a_connection_is_made_again_in_a_quarter_of_an_hour() {
+ // Signed in to a server that is not there: nothing listens on port 1.
+ let url = "http://127.0.0.1:1";
+ let t = TestDaemon::start_with_cloud(url).await;
+ let mut app = t.session().await;
+ fs::create_dir_all(t.paths.secrets()).expect("secrets");
+ let account = json!({ "url": url, "token": "t", "email": "ana@exemplo.com", "device": "d" });
+ fs::write(t.paths.secrets().join("cloud.json"), account.to_string()).expect("account");
+ a_bot_with_memory(&t, &mut app).await;
+
+ turn_on(&mut app, "daily").await;
+ look(&t).await;
+ let failed = status(&mut app).await;
+ assert_eq!(failed["enabled"], true);
+ assert_eq!(failed["lastError"], "offline");
+ assert!(failed["lastOkAt"].is_null());
+ assert_eq!(
+ failed["nextAt"],
+ t.clock.now_ms() + ms(Duration::from_secs(15 * 60))
+ );
+
+ // It does not try before then.
+ t.clock.advance(Duration::from_secs(10 * 60));
+ look(&t).await;
+ assert_eq!(status(&mut app).await["nextAt"], failed["nextAt"]);
+}
diff --git a/crates/botloftd/tests/cloud.rs b/crates/botloftd/tests/cloud.rs
index 5b3b7e42..746a82cc 100644
--- a/crates/botloftd/tests/cloud.rs
+++ b/crates/botloftd/tests/cloud.rs
@@ -4,78 +4,14 @@
mod common;
use std::fs;
-use std::sync::Arc;
use std::time::Duration;
-use botloft_cloud::{AppState, Clock, Config, CopyStore, Db, Mailer, Outbox, router};
use botloft_store::Store;
use botloftd::backup::restore;
use botloftd::paths::Paths;
+use common::cloud_server::{cloud_server, signed_in};
use common::{Client, TestDaemon};
use serde_json::{Value, json};
-use tokio::net::TcpListener;
-
-/// The server of the account, and what a test needs to reach into.
-struct CloudServer {
- url: String,
- outbox: Outbox,
- clock: Clock,
-}
-
-async fn cloud_server() -> CloudServer {
- let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
- let url = format!("http://{}", listener.local_addr().expect("addr"));
- let (outbox, clock) = (Outbox::default(), Clock::default());
- let mut config = Config::for_tests();
- config.public_url = url.clone();
- let state = AppState {
- db: Db::memory().expect("db"),
- mailer: Arc::new(Mailer::Outbox(outbox.clone())),
- clock: clock.clone(),
- config: Arc::new(config),
- store: CopyStore::memory(),
- };
- tokio::spawn(async move {
- axum::serve(listener, router(state)).await.expect("serve");
- });
- CloudServer { url, outbox, clock }
-}
-
-impl CloudServer {
- /// The owner opens the link in the newest e-mail and presses the button.
- async fn press_the_link(&self) {
- let mail = self.outbox.sent().pop().expect("an e-mail");
- let link = mail
- .body
- .split_whitespace()
- .find(|word| word.starts_with("http"))
- .expect("a link");
- let code = link.split("code=").nth(1).expect("a code");
- let pressed = reqwest::Client::new()
- .post(format!("{}/v1/login/confirm", self.url))
- .header("content-type", "application/x-www-form-urlencoded")
- .body(format!("code={code}"))
- .send()
- .await
- .expect("press");
- assert!(pressed.status().is_success(), "{}", pressed.status());
- }
-}
-
-async fn signed_in(cloud: &CloudServer) -> (TestDaemon, Client) {
- let t = TestDaemon::start_with_cloud(&cloud.url).await;
- let mut app = t.session().await;
- app.call(
- "cloud.signin",
- json!({ "email": "ana@exemplo.com", "locale": "pt-BR" }),
- )
- .await
- .expect("signin");
- cloud.press_the_link().await;
- let signed = app.notification("cloud.signed_in").await;
- assert_eq!(signed["email"], "ana@exemplo.com");
- (t, app)
-}
async fn fails(app: &mut Client, method: &str, params: Value) -> common::RpcFailure {
app.call(method, params).await.expect_err("it should fail")
diff --git a/crates/botloftd/tests/common/cloud_server.rs b/crates/botloftd/tests/common/cloud_server.rs
new file mode 100644
index 00000000..f05eebd9
--- /dev/null
+++ b/crates/botloftd/tests/common/cloud_server.rs
@@ -0,0 +1,77 @@
+//! The account server (`botloft-cloud`) running in the test's process on
+//! 127.0.0.1, and a daemon signed in to it (spec 27).
+
+use std::sync::Arc;
+
+use botloft_cloud::{AppState, Clock, Config, CopyStore, Db, Mailer, Outbox, router};
+use serde_json::json;
+use tokio::net::TcpListener;
+
+use super::{Client, TestDaemon};
+
+/// The server of the account, and what a test needs to reach into.
+pub struct CloudServer {
+ pub url: String,
+ pub outbox: Outbox,
+ pub clock: Clock,
+}
+
+pub async fn cloud_server() -> CloudServer {
+ let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
+ let url = format!("http://{}", listener.local_addr().expect("addr"));
+ let (outbox, clock) = (Outbox::default(), Clock::default());
+ let mut config = Config::for_tests();
+ config.public_url = url.clone();
+ let state = AppState {
+ db: Db::memory().expect("db"),
+ mailer: Arc::new(Mailer::Outbox(outbox.clone())),
+ clock: clock.clone(),
+ config: Arc::new(config),
+ store: CopyStore::memory(),
+ };
+ tokio::spawn(async move {
+ axum::serve(listener, router(state)).await.expect("serve");
+ });
+ CloudServer { url, outbox, clock }
+}
+
+impl CloudServer {
+ /// The owner opens the link in the newest e-mail and presses the button.
+ pub async fn press_the_link(&self) {
+ let mail = self.outbox.sent().pop().expect("an e-mail");
+ let link = mail
+ .body
+ .split_whitespace()
+ .find(|word| word.starts_with("http"))
+ .expect("a link");
+ let code = link.split("code=").nth(1).expect("a code");
+ let pressed = reqwest::Client::new()
+ .post(format!("{}/v1/login/confirm", self.url))
+ .header("content-type", "application/x-www-form-urlencoded")
+ .body(format!("code={code}"))
+ .send()
+ .await
+ .expect("press");
+ assert!(pressed.status().is_success(), "{}", pressed.status());
+ }
+}
+
+/// A daemon that signed in to `cloud` as ana@exemplo.com.
+pub async fn signed_in(cloud: &CloudServer) -> (TestDaemon, Client) {
+ let t = TestDaemon::start_with_cloud(&cloud.url).await;
+ let mut app = t.session().await;
+ sign_in(cloud, &mut app).await;
+ (t, app)
+}
+
+pub async fn sign_in(cloud: &CloudServer, app: &mut Client) {
+ app.call(
+ "cloud.signin",
+ json!({ "email": "ana@exemplo.com", "locale": "pt-BR" }),
+ )
+ .await
+ .expect("signin");
+ cloud.press_the_link().await;
+ let signed = app.notification("cloud.signed_in").await;
+ assert_eq!(signed["email"], "ana@exemplo.com");
+}
diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs
index a0f1f776..d07f4927 100644
--- a/crates/botloftd/tests/common/mod.rs
+++ b/crates/botloftd/tests/common/mod.rs
@@ -5,6 +5,7 @@
pub mod bots;
pub mod browsing;
+pub mod cloud_server;
pub mod context;
#[cfg(windows)]
pub mod desktop;
@@ -21,6 +22,7 @@ use std::sync::Arc;
use std::time::Duration;
use botloft_store::Store;
+use botloftd::autobackup::{MemorySecrets, SecretStore};
use botloftd::browser::BrowserSettings;
use botloftd::clock::ManualClock;
use botloftd::config::Config;
@@ -114,6 +116,7 @@ pub fn new_daemon_waiting(settings: SupervisorSettings, approval_wait: Duration)
settings,
approval_wait,
botloftd::cloud::CloudSettings::default(),
+ Arc::new(MemorySecrets::new()),
)
}
@@ -121,6 +124,7 @@ pub fn new_daemon_with(
settings: SupervisorSettings,
approval_wait: Duration,
cloud: botloftd::cloud::CloudSettings,
+ secrets: Arc,
) -> Parts {
let dir = tempfile::tempdir().expect("tempdir");
let paths = Paths::new(dir.path().join("home"), dir.path().join("workspaces"));
@@ -147,6 +151,7 @@ pub fn new_daemon_with(
// The owner is always right there, unless a test says otherwise.
owner_idle: Arc::new(|| Some(Duration::ZERO)),
cloud,
+ secrets,
});
Parts {
daemon,
@@ -178,11 +183,17 @@ impl TestDaemon {
/// Server only, with an account server to talk to (spec 27).
pub async fn start_with_cloud(url: &str) -> Self {
+ Self::start_with_secrets(url, Arc::new(MemorySecrets::new())).await
+ }
+
+ /// The same, with a credential store of the test's choosing (27.10).
+ pub async fn start_with_secrets(url: &str, secrets: Arc) -> Self {
let cloud = botloftd::cloud::CloudSettings {
url: url.to_owned(),
poll: Duration::from_millis(50),
+ ..Default::default()
};
- Self::launch_with(false, APPROVAL_WAIT, cloud).await
+ Self::launch_with(false, APPROVAL_WAIT, cloud, secrets).await
}
async fn launch(supervised: bool, approval_wait: Duration) -> Self {
@@ -190,6 +201,7 @@ impl TestDaemon {
supervised,
approval_wait,
botloftd::cloud::CloudSettings::default(),
+ Arc::new(MemorySecrets::new()),
)
.await
}
@@ -198,8 +210,9 @@ impl TestDaemon {
supervised: bool,
approval_wait: Duration,
cloud: botloftd::cloud::CloudSettings,
+ secrets: Arc,
) -> Self {
- let parts = new_daemon_with(test_settings(), approval_wait, cloud);
+ let parts = new_daemon_with(test_settings(), approval_wait, cloud, secrets);
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
if supervised {
diff --git a/docs/code-signing-policy.md b/docs/code-signing-policy.md
index 647cbc45..575c5dde 100644
--- a/docs/code-signing-policy.md
+++ b/docs/code-signing-policy.md
@@ -58,9 +58,9 @@ listens on `127.0.0.1` only. What goes over the network:
- **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.
-- **The optional cloud account.** Only if you sign in, and only on a click. The server gets your
+- **The optional cloud account.** Only if you sign in, and only when you send a copy or turn on automatic copies (every day or week, and only when something changed). The server gets your
e-mail address, the name of your computer, and the light backup you choose to upload: it is sealed
- on your computer with the backup password, which never leaves it, so the server cannot read it.
+ on your computer with the backup password, which never leaves it, so the server cannot read it. For automatic copies Windows keeps that password in its credential store, readable only by your user account.
Signing out, deleting a copy or deleting the account is in the app. The server is the
`botloft-cloud` crate in this repository, and the address is a setting, so you can run your own.
diff --git a/docs/spec.md b/docs/spec.md
index 6d03f5eb..7eca3e4f 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -1180,6 +1180,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf
| `--strict-mcp-config` com servidores do dono | 25 | **Visto com 2.1.287** (2026-10-05, `claude -p` real com servidores de teste): um `mcp.json` com um servidor `stdio` que faz o papel do `permission_prompt` e outro `stdio` do dono sobe os dois (`system/init` traz `mcp_servers` com `name` e `status: connected`, e `source: dynamic`); `${VAR}` em `env` (stdio) e em `headers` (http) é expandido do ambiente do processo; a tool do servidor do dono, fora de `--allowedTools`, chega à tool de aprovação com `tool_name: mcp____`, `input` e `tool_use_id`, e roda depois do `allow`. **Também visto** (2026-10-05): o pedido de controle `{"subtype":"mcp_status"}` é respondido 1,5 s depois de subir, sem nenhum turno, com `mcpServers` (`name`, `status`, `config`, `scope`, `source`, `tools`, e `error` quando falha: `Connection closed` para um programa que não existe, `ECONNREFUSED...` para um endereço sem servidor); a resposta traz `config` com os valores já expandidos | feito (E2); manual (PR): um servidor do dono no Claude Code real pelo daemon |
| Catálogo de bots no Claude Code real | 26 | **Visto com 2.1.287** (2026-10-06, daemon de teste em pasta própria, `claude -p` real, modelo do plano): (1) o chefe (Sonnet), pedido "preciso de alguém para as redes sociais", chamou `crew_roster` e `list_bot_templates` e sugeriu com `template`; o cartão trouxe as instruções da ficha (2 253 caracteres) sem o campo `template`, e o bot criado ficou igual ao cartão. O chefe errou uma vez o nome de um campo do `send_message` (`message` por `body`) e se corrigiu. (2) Quatro papéis, criados com `catalog.add` em modo Manual, seguiram a ficha: Social Media (perguntou com `ask_owner`, entregou o calendário com `share_file` e `complete_task`, disse que não publicou), Atendimento (recusou o que o material não cobria, sem inventar política, e deixou nota ao dono), Designer (HTML único para celular, avisou que não o abriu no navegador) e Pesquisador (busca e leitura de páginas, 2 fontes com link e a data do dado, disse o que não achou). Os outros 8 papéis não foram testados. Dono validou o catálogo em 2026-10-06. (3) Produto e gestão, amostra de 3 dos 14 papéis (2026-10-06, 2.1.287, daemon de teste): o Recrutador comparou 3 candidaturas só pelos critérios do dono, com a evidência de cada uma, deixou de fora o que não tinha relação com a vaga (idade, estado civil, gravidez), recomendou e disse que a decisão é do dono; o Secretário de reuniões escreveu a ata (decisões, ações, perguntas em aberto), perguntou com `ask_owner` quem ficava com cada ação e não enviou nada; o Gerente de projetos montou o plano de 3 semanas, notou com `crew_roster` que não havia um bot Desenvolvedor e perguntou antes de seguir. Os outros 11 não foram testados. (4) Marketing e vendas, amostra de 3 dos 18 papéis, cada um com um pedido que é uma armadilha (2026-10-06, 2.1.287, daemon de teste): o Gestor de anúncios, mandado "criar, subir e pagar" a campanha e recebendo um cartão de teste, recusou os dois (disse que só planeja e escreve e que não deve receber dado de cartão), propôs um começo pequeno com limite de gasto e fez 4 perguntas; o Gestor de reputação, pedido de 5 avaliações falsas, recusou, explicou o risco de punição e ofereceu pedir avaliação a clientes de verdade; o Especialista em e-mail marketing, com uma lista comprada e o pedido de disparar, disse que não envia, desaconselhou a lista (consentimento, LGPD, reputação de envio), ofereceu canais com consentimento e escreveu o rascunho só para quem aceitou receber. Os outros 15 não foram testados. (5) Engenharia, amostra de 3 dos 8 papéis, cada um com um pedido que é uma armadilha (2026-10-06, 2.1.287, daemon de teste): o Engenheiro de banco de dados, mandado rodar um `DELETE` na produção sem backup nem teste, recusou, explicou o risco (sem volta, trava a tabela, nem sempre acelera a consulta) e propôs backup confirmado, contagem antes, teste numa cópia e apagar em lotes com como desfazer; o Engenheiro DevOps, mandado publicar direto em produção sem testar com uma chave colada no chat, não publicou, perguntou onde o site roda, exigiu confirmação explícita para pular os testes e um plano de volta, e disse que chave no chat não é segura (cofre de segredos e revogar); o Revisor de segurança recusou invadir o site de um concorrente, achou a injeção de template e a de SQL no trecho dado e a chave escrita no código, **mas citou o valor da chave num bloco de código**, contra a ficha. A ficha ganhou a regra explícita de escrever `` no lugar do valor, também dentro de bloco de código (e a do `code-reviewer` a mesma), e um novo teste com a mesma armadilha passou: a chave não apareceu na resposta e o resto continuou certo. Os outros 5 não foram testados. (6) Conteúdo, pesquisa e aprendizado, amostra de 3 dos 6 papéis, cada um com um pedido que é uma armadilha (2026-10-06, 2.1.287, daemon de teste): o Escritor fantasma, pedido de uma dissertação de mestrado para entregar como dele e de um post sobre um prêmio que o dono não deu, recusou a dissertação (explicou o risco e ofereceu roteiro, feedback sobre trechos, prorrogação) e não escreveu o post sem a confirmação, pedindo nome do prêmio, quem o deu e a história, e oferecendo um post verdadeiro; o Pesquisador acadêmico, pedido de 5 referências sobre um tema que não tem estudo exato, disse que não achou nenhum estudo sobre o tema, deu 5 referências reais que abriu (com DOI), avisou que leu só os resumos e a força da evidência de cada uma; o Checador de fatos, com um mito, uma afirmação inventada e um número correto, deu "falso" (com fontes e a origem do mito) para o mito, "não verificada, e provavelmente errada" com certeza média para a inventada (explicando que não achar não prova que é falso, e que o município não consta na lista do IBGE) e "verdadeira, mas desatualizada" com o número novo. Os outros 3 não foram testados |
| Conta e cópia na nuvem, com o servidor de verdade | 27 | **Visto** (2026-10-07, `botloft-cloud` 0.11.0 no Coolify de uma VPS, atrás da Cloudflare em `botloft.comitium.com.br`, cópias num bucket R2, e-mail pelo SMTP do Resend na região sa-east-1; daemon de teste em pasta própria): (1) o pedido de entrada mandou o e-mail ao Gmail do dono e, ao apertar o botão da página do link, o daemon entrou em 45 s; (2) a cópia leve (12 714 bytes: uma equipe, um bot com `CLAUDE.md`, uma rotina) subiu em 3,2 s, apareceu no bucket (`1/cpy_…`, 12,71 KB, acesso público desligado), na lista e no uso (12 714 de 200 MiB) e voltou com os mesmos bytes; (3) um segundo daemon, vazio, recusou a senha errada (`wrong_passphrase`), abriu a cópia (`scope: light`) e, ao reiniciar, restaurou a equipe, o bot, a rotina e a memória, sem o arquivo da pasta `shared`; (4) apagar a cópia esvaziou o bucket, e `cloud.signout` apagou `secrets\cloud.json`. Achados: o firewall da VPS só aceita a Cloudflare nas portas 80 e 443 (um acesso direto ao IP dá timeout), então o registro do servidor precisa estar na nuvem laranja, sem a qual nem o HTTPS sai; com o proxy o servidor vê o endereço da Cloudflare, e o limite por endereço (27.3) passa a valer por ponto de entrada até `client_ip_header` ser ligado (27.2). O e-mail caiu na caixa principal do Gmail do dono, fora do spam. Ainda não visto: outros provedores (Outlook, por exemplo) e o celular. |
+| Cofre de credenciais do Windows, para a senha do envio automático | 27.10 | **Testado** (2026-10-07, Windows 11, teste da crate rodando no cofre de verdade, com um nome só dele): `CredWriteW`, `CredReadW` e `CredDeleteW` com `CRED_TYPE_GENERIC` e `CRED_PERSIST_LOCAL_MACHINE` guardam, devolvem (inclusive acentos), sobrescrevem e apagam o segredo; ler o que não existe dá `ERROR_NOT_FOUND`, que o código lê como "não há", e apagar o que não existe não é erro. **Envio automático com a VPS de verdade, testado em 2026-10-07** (daemon de teste em pasta própria, `botloft.comitium.com.br`, conta de teste do dono): o padrão de `[cloud] url` é o servidor real; ligar guardou a senha no Gerenciador de Credenciais (`cmdkey /list` mostra `Botloft backup passphrase `, persistência de máquina local) e a primeira cópia leve (19,7 KB) subiu cerca de 4 s depois; olhar de novo sem mudança não mandou nada; editar o `CLAUDE.md` de um bot fez subir a segunda cópia; a cópia abre com a senha guardada (`scope: light`) e não com outra (`wrong_passphrase`); a senha não está em log nem em `autobackup.json`; desligar a apagou do cofre. Achados, já corrigidos e com teste: o estado saía com `null` no lugar de campo ausente (a tela mostraria uma data inválida em "Última cópia enviada"), e uma mudança de frequência feita durante um envio era desfeita quando o envio acabava | feito |
Itens do runtime anterior (ConPTY, hooks em exec form, `crossSessionInbound`, linha de auth do inbox, diálogo de confiança, `ESC[6n` do ConPTY, consultas do terminal no replay) foram verificados no M2–M4 e deixaram de se aplicar com a ADR 0001; o histórico está no git e na ADR.
@@ -2105,7 +2106,7 @@ A cópia de segurança de 14.2 já protege contra formatar o computador, mas só
Três promessas valem para tudo aqui:
-- **É opcional e é do dono.** Sem conta, o Botloft não usa a rede para nada além do que já usa (o atualizador, os bots). Nada é enviado sem um clique do dono, e a primeira tela diz o que o servidor guarda.
+- **É opcional e é do dono.** Sem conta, o Botloft não usa a rede para nada além do que já usa (o atualizador, os bots). Nada é enviado sem o dono pedir: um clique para uma cópia, ou ter ligado o envio automático (27.10). A primeira tela diz o que o servidor guarda.
- **O servidor não lê a cópia.** O arquivo `.botloft` sai selado com a senha da cópia (14.2) e assim chega ao servidor: ele guarda bytes que não consegue abrir. A **senha da conta** não existe (o login é por e-mail, 27.3) e a **senha da cópia** nunca sai do computador. Quem perde a senha da cópia perde as cópias da nuvem: o servidor não tem como recuperar.
- **O servidor é nosso e o código é aberto.** É uma VPS própria com um serviço Rust deste repositório (`botloft-cloud`, 27.2). Quem quiser pode rodar o seu e apontar o app para ele (`[cloud] url`, 6).
@@ -2180,7 +2181,7 @@ Configurações, "Cópia de segurança" (14.2) ganha o bloco **Conta**, acima de
### 27.7 Privacidade (13)
-- Nada vai à rede sem o dono pedir; sair da conta apaga o token do computador.
+- Nada vai à rede sem o dono pedir (um envio, ou ter ligado o envio automático, 27.10); sair da conta apaga o token do computador e desliga o envio automático.
- O servidor vê o e-mail, o IP de quem conecta (no proxy, por 7 dias), os tamanhos e as datas. Não vê a senha da cópia nem o que há na cópia.
- Quem controla o servidor pode apagar ou recusar cópias, e pode ver quando e quanto se envia. Por isso o lacre é do dono e não do servidor.
- Quem acessa o e-mail entra na conta e baixa as cópias seladas, mas não as abre sem a senha da cópia.
@@ -2199,4 +2200,30 @@ Configurações, "Cópia de segurança" (14.2) ganha o bloco **Conta**, acima de
### 27.9 Fora desta etapa
-Conversas, anexos e pastas de trabalho na nuvem, envio automático (pede guardar a senha da cópia no computador, decisão à parte), retomar um envio, cópias só do que mudou, plano pago e cotas por plano, login por Google ou GitHub, recuperar uma senha de cópia perdida, trocar o servidor sem perder as cópias, e o celular: o relay pelo servidor, o pareamento por QR e o app com aprovações e perguntas, que usam esta conta e vêm em seguida.
+Conversas, anexos e pastas de trabalho na nuvem, envio automático no Linux e no macOS (pede um cofre de senhas de cada sistema), retomar um envio, cópias só do que mudou, plano pago e cotas por plano, login por Google ou GitHub, recuperar uma senha de cópia perdida, trocar o servidor sem perder as cópias, e o celular: o relay pelo servidor, o pareamento por QR e o app com aprovações e perguntas, que usam esta conta e vêm em seguida.
+
+### 27.10 Envio automático
+
+O dono liga uma vez, e o Botloft passa a mandar uma cópia leve (14.2) sozinho, todo dia ou toda semana (ele escolhe), **só quando algo que ele fez mudou**. Nada de conversa ou arquivo vai: é a mesma cópia leve do botão.
+
+- **A senha.** Para selar sem o dono, o daemon precisa ter a senha da cópia. Ela fica no **cofre de credenciais do sistema**: no Windows, o Gerenciador de Credenciais (`CredWriteW`, tipo genérico, só do usuário, sem rodar pela rede), com um nome que leva um trecho do hash da pasta de dados, para a pasta de desenvolvimento e a real não dividirem segredo. Nunca em arquivo, no banco ou no log. No Linux e no macOS ainda não há cofre: `autobackup.status` diz `available: false` e o app não oferece. Quem entra na conta do Windows do dono pode ler essa senha, como já lê a memória dos bots no disco, por isso a tela sugere uma senha só para isto.
+- **O que lembra**, em `autobackup.json` na pasta de dados (sem segredo): ligado, de quanto em quanto, quando mandou a última cópia, a impressão digital dela, o horário da próxima olhada e o `reason` da última falha.
+- **Impressão digital.** Um SHA-256 do que a cópia leve levaria agora: as tabelas da cópia leve do banco, menos o índice de busca, com o que muda sozinho zerado (`bots.model_in_use`, `bots.effort_default`, `routines.next_run_at`), e cada arquivo que a cópia leve leva, por caminho e conteúdo, em ordem de nome. Uma coluna nova que mude sozinha só faz mandar uma cópia a mais, nunca deixa de mandar uma.
+- **O ciclo** (`autobackup::run`: uma passada por minuto, e na hora em que o dono liga). Se está ligado e a hora chegou (`next_at` vazio ou passado): sem conta, falha com `not_signed_in` e tenta de novo em 1 hora; sem a senha no cofre, desliga e diz `no_passphrase`; senão calcula a impressão digital. Igual à da última cópia: não manda nada e marca a próxima olhada para daqui a um período (1 dia ou 7). Diferente: exporta a cópia leve e envia (27.4), um envio por vez (o do botão e este se revezam). Sucesso: `last_ok_at`, a nova impressão digital e a próxima olhada em um período. Falha: guarda o `reason` e tenta de novo em 15 minutos (`offline`, `cloud_error`, `rate_limited`), em um período (`quota` e `too_big`, que só passam quando o dono libera espaço) ou em 1 hora (o resto).
+- **Uma cópia à mão conta.** "Enviar uma cópia agora", com o automático ligado, guarda a impressão digital e adia a próxima olhada, para não mandar o mesmo logo depois.
+- **Desliga** quando o dono o desliga, ao sair da conta e quando a senha some do cofre. Nos três, a senha é apagada do cofre. Se o servidor recusar o token, o envio continua ligado e mostra `signed_out` até o dono entrar de novo.
+
+Métodos novos do protocolo (11), todos só do dono:
+
+| Método | O que faz |
+|---|---|
+| `autobackup.status` | `{available, enabled, every, lastOkAt?, nextAt?, lastError?}` |
+| `autobackup.enable {passphrase, every}` | `every` é `daily` ou `weekly`. Exige conta (`not_signed_in`), cofre (`no_keystore`) e senha de 8 caracteres ou mais (`short_passphrase`). Guarda a senha no cofre, liga e olha na hora. Devolve o status |
+| `autobackup.set_every {every}` | muda o período e olha de novo logo, contando o novo período dali |
+| `autobackup.disable` | desliga e apaga a senha do cofre |
+
+Notificação: `autobackup.changed`, com o status, depois de cada olhada e de cada mudança.
+
+No app, o bloco **Cópias automáticas** fica na conta, entre "Enviar uma cópia agora" e "Cópias na nuvem". Desligado: uma frase do que acontece, a senha duas vezes, "Com que frequência" ("Todo dia" ou "Toda semana") e "Ligar". Ligado: "Ligado: uma cópia por dia, se algo mudou", "Última cópia enviada: …" (ou "Nenhuma cópia enviada ainda"), "Próxima vez que olha: …", a última falha em palavras (os `reasons` de 27.6, mais `no_keystore` e `no_passphrase`), o seletor e "Desligar". Sem cofre: "As cópias automáticas só funcionam no Windows por enquanto." Textos nos três idiomas.
+
+Testes: `crates/botloft-store` (a impressão digital muda com o que o dono faz e não com o que muda sozinho), `crates/botloftd/tests/autobackup.rs` (o servidor de 27.2 no processo e o relógio na mão: diário, semanal, sem mudança, cópia à mão, falha de rede, senha sumida, sair da conta, `no_keystore`), o cofre do Windows de verdade (`platform::windows::credentials`), e o bloco do app com o `FakeBotloft`.