Skip to content
Draft
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: 2 additions & 0 deletions agents.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8"/><title>Agents — Buzz</title></head><body><script type="module" src="/src/agents.ts"></script></body></html>
4 changes: 3 additions & 1 deletion docs/agent-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ complete ordinary version-2 multi-community store.

A plugin injects `agentProtection`. `register` installs its checker and native
process requirements; `registerModels` exposes its runtime's model choices and
bounded, tool-free completion. `snapshot`, `saveDefaults` and `saveAgent` read
bounded, tool-free completion. Call both from the plugin's `agent` entry: each
page that runs agents has its own `agentProtection`, and a run checks its
actions only with the checkers registered in its page. `snapshot`, `saveDefaults` and `saveAgent` read
and update owner defaults and per-identity bindings. Saves use native revision
checks; a plugin cannot replace another provider's existing binding. Defaults
apply only when committing a new or adopted identity.
Expand Down
13 changes: 9 additions & 4 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,10 @@ agent-record migrations; blank per-agent values inherit them.
Code that agents need while they run, such as an agent type's runtime, tools,
wakes and Attention sources, belongs in the plugin's optional `agent` export:
`export const agent = { inject, apply(ctx) }`, with its own `inject`. `apply`
stays the plugin's UI. Both parts now start in the same page, under the same
plugin identity; the plugin is active once both are, and fails if either does.
This separation lets agents later run apart from the UI page.
stays the plugin's UI. Both parts start under the same plugin identity; the
plugin is active once both are, and fails if either does. In the macOS desktop
app, agent entries run in a hidden agents page and UI entries in the screens'
page; elsewhere both run in the screens' page.

A harness plugin registers its type from its `agent` entry and presents it
from `apply`. The `agent` entry registers what runs: `defaults`, `run`,
Expand Down Expand Up @@ -345,7 +346,11 @@ other watches keep no such record, so they still match them. The bundled Agent
Attention UI plugin calls it.

Protection plugins inject `ctx.agentProtection` to register checks and review
models and save native owner defaults or per-agent policies. An `AgentType`
models and save native owner defaults or per-agent policies. Register the
checker and review models from the `agent` entry, which runs beside the agents
it checks; save policies from either entry. A checker registered from `apply`
does not reach agents that run in the agents page, so their protected runs stop
as with a missing provider. An `AgentType`
declares `protection` / `protectionUnavailable` and retires native sessions with
`retire(key)`, where `key` is the membership key. Protected runs receive
`AgentHandle.protection`; missing providers or enforcement stop them. This
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ fn main() {
"clipboard_write_text",
"media_stream_base",
"get_os_idle_seconds",
"agents_port_listen",
"agents_port_send",
"workspace_pick_folder",
"repositories_folder_validate",
"legacy_communities",
Expand Down
109 changes: 109 additions & 0 deletions src-tauri/capabilities/agents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "agents-page",
"description": "The hidden page that runs agents: relay, agent, plugin host and classifier commands, without screens, windows, account setup or OS shell commands.",
"webviews": ["agents"],
"platforms": ["macOS"],
"permissions": [
"allow-identity-restore",
"allow-relay-sign",
"allow-relay-socket-connect",
"allow-relay-socket-start",
"allow-relay-socket-send",
"allow-relay-socket-close",
"allow-identity-prepare-remote-agent-authorization",
"allow-identity-sign-builderlab-binding",
"allow-relay-decode-read-state",
"allow-relay-sign-read-state",
"allow-relay-publish-read-state",
"allow-relay-http",
"allow-relay-workflow-runs",
"allow-relay-admin-discover",
"allow-relay-admin-request",
"allow-relay-admin-attachment",
"allow-relay-admin-save-attachment",
"allow-relay-project-git",
"allow-relay-project-git-cancel",
"allow-relay-channel-sign",
"allow-relay-channel-publish",
"allow-relay-kit-sign",
"allow-relay-kit-prepare",
"allow-relay-kit-decode",
"allow-relay-direct-message",
"allow-relay-decode-sidebar",
"allow-relay-sign-sidebar",
"allow-relay-decode-reminders",
"allow-relay-sign-reminder",
"allow-relay-agent-resolve",
"allow-relay-git-authorization",
"allow-relay-agent-log-proof",
"allow-relay-agent-observer",
"allow-relay-archive",
"allow-relay-agent-memories-read",
"allow-relay-agent-library",
"allow-relay-upload-begin",
"allow-relay-upload-chunk",
"allow-relay-upload",
"allow-relay-upload-cancel",
"allow-media-download",
"allow-media-stream-base",
"allow-get-os-idle-seconds",
"allow-repositories-folder-validate",
"allow-plugin-catalog",
"allow-plugin-change",
"allow-plugin-reload",
"allow-plugin-module",
"allow-plugin-recover",
"allow-plugin-host-run-command",
"allow-plugin-host-request",
"allow-plugin-host-upload",
"allow-plugin-host-process-spawn",
"allow-plugin-host-process-write",
"allow-plugin-host-process-kill",
"allow-plugin-host-mcp-serve",
"allow-plugin-host-mcp-reply",
"allow-plugin-host-mcp-close",
"allow-plugin-host-read-file",
"allow-plugin-host-resolve-directory",
"allow-oauth-callback-begin",
"allow-oauth-callback-wait",
"allow-oauth-callback-cancel",
"allow-app-agent-list",
"allow-app-agent-create",
"allow-app-agent-portable",
"allow-app-agent-port",
"allow-app-agent-rename",
"allow-app-agent-set-picture",
"allow-app-agent-set-type",
"allow-app-agent-delete",
"allow-app-agent-begin-delete",
"allow-app-agent-begin-leave",
"allow-app-agent-hold-removal",
"allow-app-agent-release-removal",
"allow-app-agent-join",
"allow-app-agent-forget",
"allow-app-agent-publish",
"allow-app-agent-publish-profile",
"allow-app-agent-query",
"allow-app-agent-search-profiles",
"allow-app-agent-upload",
"allow-app-agent-media",
"allow-app-agent-git-auth",
"allow-app-agent-remember",
"allow-app-agent-claim",
"allow-app-agent-release",
"allow-app-agent-set-protection",
"allow-app-agent-protection-defaults",
"allow-app-agent-set-protection-defaults",
"allow-jev-classifier-status",
"allow-jev-classifier-set-key",
"allow-jev-classifier-clear-key",
"allow-app-agent-state-load",
"allow-app-agent-state-set",
"allow-decide",
"core:app:allow-version",
"core:resources:allow-close",
"allow-agents-port-listen",
"allow-agents-port-send"
]
}
2 changes: 2 additions & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@
"core:window:allow-close",
"core:window:allow-start-dragging",
"core:window:allow-internal-toggle-maximize",
"allow-agents-port-listen",
"allow-agents-port-send",
{
"identifier": "opener:allow-open-url",
"allow": [
Expand Down
119 changes: 119 additions & 0 deletions src-tauri/src/agents_port.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
//! The port between the screens' page and the page that runs agents. Each
//! page opens its end with a channel, and a frame one page sends goes to the
//! other's channel. Only those two webviews take part, and the sender is the
//! webview the frame came from, so neither page listens to app events and no
//! other page can speak for either.

use std::{collections::HashMap, sync::Mutex};

use serde_json::Value;
use tauri::{ipc::Channel, Runtime, State, Webview};

const PAGES: [&str; 2] = ["main", "agents"];

/// The other page of the pair, for `label`.
fn peer(label: &str) -> Option<&'static str> {
match label {
"main" => Some("agents"),
"agents" => Some("main"),
_ => None,
}
}

/// Each page's open end, by its webview's label. A page that loads again
/// opens a new one, which replaces the last.
#[derive(Default)]
pub(crate) struct AgentsPort(Mutex<HashMap<&'static str, Channel<Value>>>);

impl AgentsPort {
fn open(&self, label: &str, frames: Channel<Value>) -> Result<(), String> {
let page = PAGES
.into_iter()
.find(|page| *page == label)
.ok_or("Only Buzz's own pages use the agents port")?;
self.0
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(page, frames);
Ok(())
}
/// Sends `frame` from `label`'s page to its peer's. A peer that is not
/// open yet misses it; it says when it is, and the sender sends again.
fn send(&self, label: &str, frame: Value) -> Result<(), String> {
let to = peer(label).ok_or("Only Buzz's own pages use the agents port")?;
let channel = self
.0
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(to)
.cloned();
match channel {
Some(channel) => channel.send(frame).map_err(|error| error.to_string()),
None => Ok(()),
}
}
}

#[tauri::command]
pub(crate) fn agents_port_listen<R: Runtime>(
webview: Webview<R>,
port: State<'_, AgentsPort>,
frames: Channel<Value>,
) -> Result<(), String> {
port.open(webview.label(), frames)
}

#[tauri::command]
pub(crate) fn agents_port_send<R: Runtime>(
webview: Webview<R>,
port: State<'_, AgentsPort>,
frame: Value,
) -> Result<(), String> {
port.send(webview.label(), frame)
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex as StdMutex};
use tauri::ipc::InvokeResponseBody;

fn end() -> (Channel<Value>, Arc<StdMutex<Vec<Value>>>) {
let heard = Arc::new(StdMutex::new(Vec::new()));
let into = heard.clone();
let channel = Channel::new(move |body| {
if let InvokeResponseBody::Json(json) = body {
into.lock()
.unwrap()
.push(serde_json::from_str(&json).unwrap());
}
Ok(())
});
(channel, heard)
}

#[test]
fn frames_go_only_to_the_other_page_of_the_pair() {
let port = AgentsPort::default();
let (main, to_main) = end();
let (agents, to_agents) = end();
// Before the other page is open, a frame for it is missed, not held.
port.send("main", Value::from("early")).unwrap();
port.open("main", main).unwrap();
port.open("agents", agents).unwrap();
port.send("main", Value::from(1)).unwrap();
port.send("agents", Value::from(2)).unwrap();
assert_eq!(*to_agents.lock().unwrap(), vec![Value::from(1)]);
assert_eq!(*to_main.lock().unwrap(), vec![Value::from(2)]);
// A reloaded page's new end replaces its old one.
let (again, to_again) = end();
port.open("agents", again).unwrap();
port.send("main", Value::from(3)).unwrap();
assert_eq!(*to_again.lock().unwrap(), vec![Value::from(3)]);
assert_eq!(to_agents.lock().unwrap().len(), 1);
// No other page opens an end or sends.
let (huddle, _) = end();
assert!(port.open("huddle", huddle).is_err());
assert!(port.send("huddle", Value::from(4)).is_err());
}
}
Loading
Loading