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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon
- Código, identificadores, comentários, commits e README em **inglês**. Docs em `docs/` podem ser em português.
- Arquivos com no máximo **~300 linhas**. Passou disso, divida por responsabilidade.
- Erros: `thiserror` nos crates de biblioteca, `anyhow` só no binário.
- IDs são ULID com prefixo (`crw_`, `bot_`, `msg_`, `dlv_`, `tsk_`, `cht_`, `apr_`, `att_`, `qst_`). Tempo em ms Unix.
- IDs são ULID com prefixo (`crw_`, `bot_`, `msg_`, `dlv_`, `tsk_`, `cht_`, `apr_`, `att_`, `qst_`, `dsk_`). Tempo em ms Unix.
- Tudo que é específico de Windows fica em `crates/botloftd/src/platform/`. O resto do daemon não chama Win32 direto.
- A UI depende só da interface `BotloftApi` (`app/src/lib/api.ts`). Testes de componente usam `FakeBotloft`.
- Nenhum texto de interface escrito direto no componente: tudo vai em `app/src/i18n/{en,pt-BR,es}/<área>.ts` e é lido com `useT()` (ou `t()` fora do React). Texto novo entra nos três idiomas no mesmo commit; o TypeScript acusa o que faltar (spec 15.6). Palavras simples, sem "daemon" nem outro jargão.
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { FakeBrowser } from "./fakeBrowser";
import { FakeChat } from "./fakeChat";
import { FakeConversation } from "./fakeConversation";
import { crewHandlers } from "./fakeCrews";
import { FakeDesktop } from "./fakeDesktop";
import { FakeFiles } from "./fakeFiles";
import { FakeLesson } from "./fakeLesson";
import { FakeQuestions } from "./fakeQuestions";
Expand Down Expand Up @@ -73,6 +74,7 @@ export class FakeBotloft implements BotloftApi {
readonly calls: { method: Method; params: unknown }[] = [];
readonly chat = new FakeChat(this);
readonly allow = new FakeAllow(this);
readonly desktop = new FakeDesktop(this);
readonly conversation = new FakeConversation(this);
readonly routines = new FakeRoutines(this);
readonly files = new FakeFiles(this);
Expand Down Expand Up @@ -259,6 +261,7 @@ export class FakeBotloft implements BotloftApi {
...botHandlers(this),
...this.chat.handlers(),
...this.allow.handlers(),
...this.desktop.handlers(),
...this.conversation.handlers(),
...this.files.handlers(),
...this.browser.handlers(),
Expand Down
69 changes: 69 additions & 0 deletions app/src/lib/fakeDesktop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// The fake daemon's desktop grants (spec 24.2, 24.10): what each bot may
// see and do on the owner's desktop, and `desktop.*`. The bots' desktop
// tools are not played: grants come from `grant`, as the real daemon makes
// them when the owner allows a request.

import type { FakeBotloft, Handlers } from "./fake";
import { notFound } from "./fakeRules";
import type { BotId, DesktopGrant, DesktopLevel } from "./protocol.gen";

type DesktopMethods = Extract<keyof Handlers, `desktop.${string}`>;

export class FakeDesktop {
readonly grants: DesktopGrant[] = [];

constructor(private readonly fake: FakeBotloft) {}

/** Lets the bot see, or see and act, in an app, and tells the app. */
grant(botId: BotId, appPath: string, appName: string, level: DesktopLevel = "see"): DesktopGrant {
const same = this.grants.find(
(grant) => grant.botId === botId && grant.appPath?.toLowerCase() === appPath.toLowerCase(),
);
const grant: DesktopGrant = same ?? {
id: this.fake.id("dsk"),
botId,
scope: "app",
appPath,
appName,
level,
realInput: false,
unattended: false,
acceptedRisksAt: null,
createdAt: this.fake.now,
};
if (same) {
same.level = same.level === "act" || level === "act" ? "act" : "see";
} else {
this.grants.push(grant);
}
this.changed(botId);
return grant;
}

private of(botId: BotId): DesktopGrant[] {
return this.grants.filter((grant) => grant.botId === botId);
}

private changed(botId: BotId) {
this.fake.emit({ name: "bot.desktop", params: { botId, grants: this.of(botId) } });
}

handlers(): Pick<Handlers, DesktopMethods> {
return {
"desktop.grants": ({ botId }) => {
this.fake.bot(botId);
return this.of(botId);
},
"desktop.revoke": ({ grantId }) => {
const at = this.grants.findIndex((grant) => grant.id === grantId);
const grant = this.grants[at];
if (!grant) {
throw notFound(`desktop grant ${grantId}`);
}
this.grants.splice(at, 1);
this.changed(grant.botId);
return { botId: grant.botId, grants: this.of(grant.botId) };
},
};
}
}
62 changes: 62 additions & 0 deletions app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export type RuleId = string;
*/
export type QuestionId = string;

/**
* Identifies what the owner let a bot see or do on their desktop
* (spec 24.2).
*/
export type DesktopGrantId = string;

/**
* A group of bots that can message each other and share a folder.
*/
Expand Down Expand Up @@ -810,6 +816,59 @@ export type BotRules = { botId: BotId,
*/
rules: Array<AllowRule>, };

/**
* What a grant reaches.
*/
export type DesktopScope = "app" | "desktop";

/**
* What a grant lets the bot do: see, or see and act.
*/
export type DesktopLevel = "see" | "act";

/**
* A grant of a bot on the owner's desktop.
*/
export type DesktopGrant = { id: DesktopGrantId, botId: BotId, scope: DesktopScope,
/**
* The executable, for an `app` grant.
*/
appPath: string | null,
/**
* The app's name for people ("Microsoft Excel"), for an `app` grant.
*/
appName: string | null, level: DesktopLevel,
/**
* The bot may use the owner's real mouse and keyboard (spec 24.7).
*/
realInput: boolean,
/**
* The bot may use it while the owner is away (spec 24.8).
*/
unattended: boolean,
/**
* When the owner accepted the risks screen (spec 24.10), Unix ms.
*/
acceptedRisksAt: number | null,
/**
* Unix time in milliseconds.
*/
createdAt: number, };

export type DesktopGrantsParams = { botId: BotId, };

export type DesktopGrantIdParams = { grantId: DesktopGrantId, };

/**
* A bot's grants after one changed: the result of `desktop.revoke` and
* the params of the `bot.desktop` notification.
*/
export type BotDesktop = { botId: BotId,
/**
* Oldest first.
*/
grants: Array<DesktopGrant>, };

export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string,
/**
* What the bot says the command is for, as in `ToolItem`. The bot
Expand Down Expand Up @@ -1490,6 +1549,8 @@ export interface RpcMethods {
"usage.tokens": { params: UsageTokensParams; result: Array<BotTokens> };
"rules.list": { params: RulesListParams; result: Array<AllowRule> };
"rules.delete": { params: RuleIdParams; result: BotRules };
"desktop.grants": { params: DesktopGrantsParams; result: Array<DesktopGrant> };
"desktop.revoke": { params: DesktopGrantIdParams; result: BotDesktop };
"routines.list": { params: RoutinesListParams; result: Array<Routine> };
"routines.create": { params: RoutinesCreateParams; result: Routine };
"routines.update": { params: RoutinesUpdateParams; result: Routine };
Expand Down Expand Up @@ -1538,6 +1599,7 @@ export interface RpcNotifications {
"question.changed": Question;
"reaction.changed": ReactionChanged;
"bot.rules": BotRules;
"bot.desktop": BotDesktop;
"browser.changed": BrowserState;
"browser.action": BrowserAction;
"browser.frame": BrowserFrame;
Expand Down
7 changes: 7 additions & 0 deletions crates/botloft-core/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pub const DELETE_ROUTINE_TOOL: &str = "mcp__botloft__delete_routine";
/// model calls: the daemon opens this request from inside the `browser_*`
/// tools.
pub const BROWSER_SITE_TOOL: &str = "mcp__botloft__browser";
/// A bot asking to see or use an app on the owner's desktop (spec 24.2),
/// opened from inside the `desktop_*` tools.
pub const DESKTOP_TOOL: &str = "mcp__botloft__desktop";
/// A bot asking the owner to do something in its browser themselves (spec
/// 21.10), opened from inside `browser_ask_owner`.
pub const BROWSER_HELP_TOOL: &str = "mcp__botloft__browser_help";
Expand Down Expand Up @@ -111,6 +114,10 @@ pub fn tool_summary(name: &str, input: &Value) -> String {
field(input, "name").map(str::to_owned)
}
BROWSER_SITE_TOOL => field(input, "site").map(str::to_owned),
DESKTOP_TOOL => field(input, "app").map(str::to_owned),
"mcp__botloft__desktop_look" | "mcp__botloft__desktop_screenshot" => {
field(input, "why").map(str::to_owned)
}
BROWSER_HELP_TOOL | "mcp__botloft__browser_ask_owner" => {
field(input, "task").map(str::to_owned)
}
Expand Down
5 changes: 5 additions & 0 deletions crates/botloft-core/src/ids.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ prefixed_id!(
/// Identifies a question a bot asked the owner (spec 23).
QuestionId, "question", "qst_"
);
prefixed_id!(
/// Identifies what the owner let a bot see or do on their desktop
/// (spec 24.2).
DesktopGrantId, "desktop grant", "dsk_"
);

/// A random version 4 UUID, for Claude Code session ids and the uuid of
/// each message written to a bot (spec 7.3, 9.2).
Expand Down
109 changes: 109 additions & 0 deletions crates/botloft-core/src/protocol/desktop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//! What the owner let each bot see and do on their desktop (spec 24.2).

use serde::{Deserialize, Serialize};

use crate::ids::{BotId, DesktopGrantId};

/// What a grant reaches.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub enum DesktopScope {
/// The windows of one program, by its executable.
App,
/// Every window, but what is never granted (spec 24.3).
Desktop,
}

impl DesktopScope {
pub fn as_str(self) -> &'static str {
match self {
Self::App => "app",
Self::Desktop => "desktop",
}
}

pub fn parse(text: &str) -> Option<Self> {
Some(match text {
"app" => Self::App,
"desktop" => Self::Desktop,
_ => return None,
})
}
}

/// What a grant lets the bot do: see, or see and act.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub enum DesktopLevel {
/// List the windows, read them and take pictures of them.
See,
/// Also click, type, choose and scroll (spec 24.5).
Act,
}

impl DesktopLevel {
pub fn as_str(self) -> &'static str {
match self {
Self::See => "see",
Self::Act => "act",
}
}

pub fn parse(text: &str) -> Option<Self> {
Some(match text {
"see" => Self::See,
"act" => Self::Act,
_ => return None,
})
}
}

/// A grant of a bot on the owner's desktop.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct DesktopGrant {
pub id: DesktopGrantId,
pub bot_id: BotId,
pub scope: DesktopScope,
/// The executable, for an `app` grant.
pub app_path: Option<String>,
/// The app's name for people ("Microsoft Excel"), for an `app` grant.
pub app_name: Option<String>,
pub level: DesktopLevel,
/// The bot may use the owner's real mouse and keyboard (spec 24.7).
pub real_input: bool,
/// The bot may use it while the owner is away (spec 24.8).
pub unattended: bool,
/// When the owner accepted the risks screen (spec 24.10), Unix ms.
pub accepted_risks_at: Option<i64>,
/// Unix time in milliseconds.
pub created_at: i64,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct DesktopGrantsParams {
pub bot_id: BotId,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct DesktopGrantIdParams {
pub grant_id: DesktopGrantId,
}

/// A bot's grants after one changed: the result of `desktop.revoke` and
/// the params of the `bot.desktop` notification.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(test, derive(ts_rs::TS))]
pub struct BotDesktop {
pub bot_id: BotId,
/// Oldest first.
pub grants: Vec<DesktopGrant>,
}
10 changes: 8 additions & 2 deletions crates/botloft-core/src/protocol/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ use ts_rs::{Config, TS};
use super::*;
use crate::avatar::PALETTE;
use crate::ids::{
ApprovalId, AttachmentId, BotId, ChatItemId, CrewId, DeliveryId, MessageId, QuestionId,
RoutineId, RoutineRunId, RuleId, TaskId,
ApprovalId, AttachmentId, BotId, ChatItemId, CrewId, DeliveryId, DesktopGrantId, MessageId,
QuestionId, RoutineId, RoutineRunId, RuleId, TaskId,
};

#[path = "export_browser.rs"]
mod browser;
#[path = "export_desktop.rs"]
mod desktop;
#[path = "export_questions.rs"]
mod questions;
#[path = "export_reactions.rs"]
Expand Down Expand Up @@ -107,6 +109,7 @@ fn export_bindings() {
out.decl::<RoutineRunId>();
out.decl::<RuleId>();
out.decl::<QuestionId>();
out.decl::<DesktopGrantId>();
out.decl::<Crew>();
out.decl::<BotState>();
out.decl::<PermissionMode>();
Expand Down Expand Up @@ -175,6 +178,7 @@ fn export_bindings() {
out.decl::<ReplyItem>();
out.decl::<ToolItem>();
rules::decls(&mut out);
desktop::decls(&mut out);
out.decl::<ApprovalItem>();
usage::decls(&mut out);
questions::decls(&mut out);
Expand Down Expand Up @@ -338,6 +342,7 @@ fn export_bindings() {
session::methods(&mut out);
usage::methods(&mut out);
rules::methods(&mut out);
desktop::methods(&mut out);
routines::methods(&mut out);
questions::methods(&mut out);
reactions::methods(&mut out);
Expand Down Expand Up @@ -389,6 +394,7 @@ fn export_bindings() {
questions::notifications(&mut out);
reactions::notifications(&mut out);
rules::notifications(&mut out);
desktop::notifications(&mut out);
browser::notifications(&mut out);
out.text.push_str("}\n\n");

Expand Down
Loading
Loading