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
6 changes: 4 additions & 2 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,10 @@ that agent in Buzz; they do not notify the external app through this connection.
uploads are available. Files and media are shared with the conversation's
members. If an upload fails, its attachment shows the reason, **Retry**, and
**Remove**. Follow the error's guidance before retrying, or remove the attachment
to unblock Send; your typed message stays in the composer. Do not promise
support for every file type or size.
to unblock Send; your typed message stays in the composer. Some plugins supply
temporary attachment sources that may be released after a confirmed post; keep
the posted link for reuse, and do not assume local cleanup will happen
automatically. Do not promise support for every file type or size.
- **Emoji suggestions** appear when you type `:` and an emoji name. If a name
is shortened with an ellipsis, hover over it or use the arrow keys to select
it and see the full name in a tooltip.
Expand Down
83 changes: 81 additions & 2 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1500,8 +1500,87 @@ refuse. Only the verified fixed ownership file is excluded.
The shared root permits at most 64 MiB and 16 artifact files. Any plugin can fill
it; there is no per-plugin quota or fairness promise. A full root refuses before
another file is created, without evicting unexpired files. Failures remove output
best-effort; residue remains counted. There is no index, sidecar, recovery adoption,
content deduplication or temporary-to-final file service.
best-effort; residue remains counted. There is no persistent index, sidecar,
recovery adoption, content deduplication or temporary-to-final file service.
The optional after-send policy below uses only a bounded in-memory consent map.

#### Opt-in temporary attachment release

A compatible desktop frontend/native pair accepts
`await host.saveBytes(bytes, {sha256, releaseAfterSend: true})`. The flag defaults
to off; false/omission preserves ordinary saves. Require the returned
`releaseAfterSend === true` acknowledgement. An older native host may save while
ignoring the option: missing/false acknowledgement rejects as unsupported and
**possibly saved**, never permission to retry. Establish a compatible released
pair before enabling a consumer; method detection alone is not evidence.
No new grant, caller path/account, deletion callback or release command exists.

Native captures the saving **human Nostr owner**, not a plugin ID or federated
login account. Opt-in saves may refuse if identity is unavailable, closed or busy
at the final commit. Consent insertion and identity close share a short native
fence; ordinary saves remain identity-independent. Native command timeout/drop
before commit prevents consent. Renderer disposal/page retirement/lost IPC replies
are not native cancellation signals: they can leave a possibly saved file and
committed consent even before the renderer learns the result. Never automatically
save again to recover an unknown outcome.

The app's shared attachment tools collect bounded local hints after read/upload,
using the bytes' exact SHA256/size and a matching upload descriptor. These hints
travel outside signed event tags and tool arguments. They do not create deletion
authority. Only after the **full native evidence-wrapped publication** succeeds
with the relay accepting that exact signed kind-9 event ID does native attempt
release. Upload, local event insertion, renderer claims and an unconfirmed receipt
are insufficient. This is not recipient delivery/read or permanent remote storage.
The trusted app supplies read/upload mapping; no independent cryptographic native
provenance or malicious-plugin sandbox is claimed. Standalone/browser clients do
not gain a local deletion implementation.

A different runtime plugin may send the saving plugin's source for the same human
owner. Native requires its process-local save consent, exact direct-child path,
saver digest, private no-link file identity, content hash/size, and the confirmed
event's URL/hash/size together in one imeta. Saver update/disable and later UI or
federated-session changes do not retarget previously committed consent. Unknown,
legacy, replaced, aliased, unmarked or other-owner files never acquire consent
from filenames or tool hints. Platforms without safely verifiable file identity
retain the source. Public explicit deletion retains its existing grant/owner rules.

One bounded best-effort attempt per distinct source uses the shared byte permit,
root writer and reader exclusion, with **one shared 30-second cleanup-entry budget
for the whole message**, including scheduling and queue wait. The confirmed send
returns immediately without awaiting cleanup; cleanup runs detached on the app's
native runtime, not the runner lifetime. Runner disposal does not cancel it.
The deadline is captured before spawning, and actual blocking work keeps its
byte/root/reader guards until completion. App shutdown need not drain cleanup:
undispatched work can be lost and sources retained; dispatched unlink may finish
with an unknown outcome. No work is durable or required to outlive the app.
Failure/refusal never changes a confirmed send into an error or repost.
Active app-wide readers refuse cleanup;
while cleanup verifies content, new direct/process reads instead promptly refuse
with `File reads are busy; try again later`, without spawning/reading or silently
retrying. A stalled hash can outlive the response budget; dispatched work retains
admission/exclusion through real completion and its unlink outcome can be unknown.

| Outcome | Source and caller behavior |
| --- | --- |
| Read/upload/body/mention/emoji failure, or native-unconfirmed publish | Keep sources; no cleanup or automatic resend. Earlier uploads may exist remotely. |
| Confirmed native publish, cleanup succeeds | Local source removed and quota reclaimed; posted message/media unchanged. |
| Confirmed publish, busy reader/root/queue, mismatch or cleanup error | Send still succeeds; source may remain indefinitely, without a cleanup retry. |
| Dispatched cleanup exceeds the shared deadline | Send succeeds; local cleanup outcome unknown and actual work may finish late. |
| Second sequential send of a released path | Normal missing-file failure before upload; reuse the posted link, not the old source. |
| Concurrent send with bytes already read | It may finish from its snapshot even after the first confirmed send releases the source. Future reads are not reserved. |
| Restart/crash/different process | Consent is lost, never adopted from a filename; files may remain indefinitely. |

**Cleanup may never happen automatically; expiry only makes a file eligible for removal during a later save.**
There is no durable/cross-process consent, background sweep, completion callback
or eventual-erasure guarantee. Only this new after-send path requires confirmed
publish; owner deletion, lazy expiry sweeping, erasure and disk loss remain
independent. Save acknowledgement is not a publication/deletion receipt;
`sent <id>` confirms publication, not local deletion.

When returning an opted-in path, explain: “Temporary attachment source: send this
file once. After a confirmed post, the host may release the local copy; keep the
posted link for reuse. Failed or uncertain sends are not safe to repeat
automatically.” Also disclose that cleanup refusal may retain it indefinitely.

Optional `host.deleteManagedFile(path)` requires `host.managedFiles` and the current
enabled plugin revision, not `host.files`. It accepts only a direct, host-generated
Expand Down
168 changes: 163 additions & 5 deletions src-tauri/src/app_agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -878,23 +878,67 @@ pub(crate) struct AppAgentEvent {

/// Signs one event as the agent and posts it to the agent's own community.
#[tauri::command]
pub(crate) async fn app_agent_publish(
pub(crate) async fn app_agent_publish<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
state: tauri::State<'_, AppAgentHost>,
pubkey: String,
relay: String,
event: AppAgentEvent,
managed_attachments: Option<Value>,
) -> Result<Value, String> {
use tauri::Manager as _;
let (agent, key, evidence) = state.signer(pubkey, relay).await?;
let signed = agent.sign(&key, event.kind, event.content, event.tags)?;
let root = app
.path()
.app_data_dir()
.ok()
.map(|root| root.join("plugin-artifacts"));
confirmed_post(
&agent,
&key,
evidence,
signed,
app.state::<crate::host_files::ManagedFiles>()
.inner()
.clone(),
root,
managed_attachments,
)
.await
}

async fn confirmed_post(
agent: &AppAgent,
key: &Secret,
evidence: Admission,
signed: Value,
registry: crate::host_files::ManagedFiles,
root: Option<PathBuf>,
managed_attachments: Option<Value>,
) -> Result<Value, String> {
let event_id = signed
.get("id")
.and_then(Value::as_str)
.ok_or("Invalid agent event")?
.to_owned();
evidence
.run(true, post(&agent, &key, evidence.header(), signed))
let confirmed = evidence
.run(true, post(agent, key, evidence.header(), signed))
.await
.map_err(|error| publish_error(&event_id, error))
.map_err(|error| publish_error(&event_id, error))?;
if let (Some(root), Some(hints)) = (root, managed_attachments) {
// App-runtime-owned, not runner-owned: confirmation never waits for byte
// admission or cleanup. The future captures its deadline before spawning.
// App exit need not drain it; undispatched work may leave the source behind.
tauri::async_runtime::spawn(crate::host_files::release_after_send(
registry,
root,
agent.owner.clone(),
confirmed.clone(),
Some(hints),
));
}
Ok(confirmed)
}

fn publish_error(event_id: &str, error: String) -> String {
Expand Down Expand Up @@ -2010,7 +2054,7 @@ mod tests {
(origin, served)
}

fn agent_at(relay: String, key: &Secret) -> AppAgent {
pub(super) fn agent_at(relay: String, key: &Secret) -> AppAgent {
AppAgent {
pubkey: key.pubkey().into(),
relay,
Expand Down Expand Up @@ -2574,3 +2618,117 @@ mod tests {
}
}
}

#[cfg(test)]
mod managed_publish_tests {
use super::*;
use crate::host_files::{
managed_tests::{commands, release_completion, release_fixture_data, Fixture},
ManagedFiles,
};
use tauri::Manager as _;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

/// The actual HTTP post/evidence wrapper + real managed save/unlink. Loopback
/// fixture only: not Wry/trusted-HTTPS or native signing-custody acceptance.
#[cfg(unix)]
#[test]
fn host_release_confirmed_send_returns_before_cleanup_byte_admission_and_preserves_evidence() {
check_managed_publish_outcomes(true);
}

#[cfg(not(unix))]
#[test]
fn host_release_confirmed_send_retains_source_without_platform_file_identity() {
check_managed_publish_outcomes(false);
}

fn check_managed_publish_outcomes(releases_accepted_source: bool) {
let _serial = commands();
for outcome in [
"accepted",
"byte-busy",
"refused",
"wrong-id",
"bad-json",
"lost-reply",
"http-error",
"retired",
"cleanup-refused",
] {
let f = Fixture::new(true, false);
let (hints, event, owner) = release_fixture_data(&f, b"native confirmed source");
let path = PathBuf::from(hints[0]["path"].as_str().unwrap());
let registry = f.app.state::<ManagedFiles>().inner().clone();
let complete = release_completion(&registry);
let root = f.root();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let tokens = AgentTokens::unconfigured();
let key = Secret::generate().unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut agent = tests::agent_at("wss://relay.example".into(), &key);
agent.owner = owner;
let stamp = tokens.stamp(&agent.pubkey, &agent.relay).unwrap();
let evidence = tokens.authorize(&agent, &key, stamp).await.unwrap();
let retire = tokens.clone();
let pubkey = agent.pubkey.clone();
let relay = agent.relay.clone();
agent.relay = format!("http://{}", listener.local_addr().unwrap());
let tags = serde_json::from_value(event["tags"].clone()).unwrap();
let signed = agent.sign(&key, 9, "attachment".into(), tags).unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut head = vec![];
while !head.ends_with(b"\r\n\r\n") {
let mut byte = [0]; socket.read_exact(&mut byte).await.unwrap(); head.push(byte[0]);
}
let head = String::from_utf8(head).unwrap();
let size: usize = head.lines().find_map(|line| line.to_ascii_lowercase().strip_prefix("content-length: ").map(str::to_owned)).unwrap().parse().unwrap();
let mut body = vec![0; size]; socket.read_exact(&mut body).await.unwrap();
let received: Value = serde_json::from_slice(&body).unwrap();
if outcome == "retired" { retire.forget_row(&pubkey, &relay).unwrap(); }
if outcome == "lost-reply" { return received; }
let receipt = match outcome {
"refused" => serde_json::json!({"accepted":false,"event_id":received["id"]}).to_string(),
"wrong-id" => serde_json::json!({"accepted":true,"event_id":"b".repeat(64)}).to_string(),
"bad-json" => "not JSON".into(),
_ => serde_json::json!({"accepted":true,"event_id":received["id"]}).to_string(),
};
let status = if outcome == "http-error" { "403 Forbidden" } else { "200 OK" };
socket.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{receipt}", receipt.len()).as_bytes()).await.unwrap();
received
});
let lease = if outcome == "cleanup-refused" { Some(crate::host_files::FileReadLease::new().unwrap()) } else { None };
let held = if outcome == "byte-busy" {
Some(crate::host_files::BYTE_WORK.acquire().await.unwrap())
} else {
None
};
let publish = tokio::time::timeout(
std::time::Duration::from_secs(2),
confirmed_post(&agent, &key, evidence, signed, registry, Some(root), Some(hints)),
).await;
let received = server.await.unwrap(); // Exactly one connection/request; no repost path.
if held.is_some() {
assert!(path.exists(), "busy byte permit must prevent early cleanup");
}
let result = publish.expect("confirmed send waited for cleanup byte admission");
drop(held);
if matches!(outcome, "accepted" | "byte-busy" | "cleanup-refused") {
assert_eq!(result.unwrap()["id"], received["id"]);
tokio::time::timeout(std::time::Duration::from_secs(10), complete)
.await.expect("detached cleanup did not finish").unwrap();
assert_eq!(path.exists(), !releases_accepted_source || outcome == "cleanup-refused");
} else {
assert!(result.is_err(), "{outcome} must be unconfirmed/refused");
assert!(path.exists(), "{outcome} must retain source");
}
drop(lease);
});
}
}
}
Loading
Loading