Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
2e40cda
feat: remote agent backend providers
tlongwell-block Mar 20, 2026
0f62e75
Merge origin/main into feat/remote-agent-providers
tlongwell-block Mar 20, 2026
a06a188
fix: pipe deadlock, channel UUID resolution, probe validation
tlongwell-block Mar 20, 2026
35405a5
fix: rename backend_agent_id to backendAgentId in TypeScript types
tlongwell-block Mar 20, 2026
0244cf2
fix: make set_agent_owner atomic (first-mint-wins)
tlongwell-block Mar 20, 2026
3af505a
refactor: extract deploy helper, kill orphaned providers, catch camel…
tlongwell-block Mar 20, 2026
402672b
fix: safe child kill, acronym-aware secret detection, relay channel U…
tlongwell-block Mar 20, 2026
9a8d8d1
fix: reliable child kill on timeout, correct channel UUID resolution
tlongwell-block Mar 20, 2026
957a5d3
fix: correct invoke_provider timeout — try_wait loop with pipe draining
tlongwell-block Mar 20, 2026
d8aa5c9
fix: paired channel name+UUID from DB, propagate DB errors in owner c…
tlongwell-block Mar 20, 2026
a41e88a
fix: include exit code in provider error messages
tlongwell-block Mar 20, 2026
0af8630
fix: fall back to channel name→UUID lookup when relay lacks channel_ids
tlongwell-block Mar 20, 2026
0a4afbc
Merge remote-tracking branch 'origin/main' into feat/remote-agent-pro…
tlongwell-block Mar 20, 2026
0fe3f68
Merge origin/main into feat/remote-agent-providers
tlongwell-block Mar 20, 2026
1b1c5b9
fix: update rustls-webpki 0.103.10, ignore RUSTSEC-2026-0049 for 0.101
tlongwell-block Mar 20, 2026
dadefea
Merge origin/main into feat/remote-agent-providers
tlongwell-block Mar 20, 2026
861fb94
fix: tighten provider IPC, log revocation failures, remove redundant …
tlongwell-block Mar 20, 2026
bb671b0
fix: harden owner lookup, filter non-executable providers, document s…
tlongwell-block Mar 20, 2026
ef1ddb0
fix: close remote lifecycle gaps — require mint, prevent zombies, gua…
tlongwell-block Mar 20, 2026
a796866
fix: enforce provider scopes, unify mint state, type-coerce provider …
tlongwell-block Mar 20, 2026
5692b73
fix: skip local prereqs for providers, bounded pipe joins, unique cha…
tlongwell-block Mar 20, 2026
61a1afe
fix: channel-based provider IO — no read_to_end, no thread leaks
tlongwell-block Mar 20, 2026
503fbe2
fix: raw-chunk stdout with incremental JSON parse, bounded stderr via…
tlongwell-block Mar 20, 2026
13a8e53
fix: resolve provider binaries via discovered candidates only
tlongwell-block Mar 20, 2026
94be3b5
fix: enforce remote-delete guard at backend boundary
tlongwell-block Mar 20, 2026
6b2d25a
fix: cap stdout at 1MB, fail admin mint on owner mismatch, model dele…
tlongwell-block Mar 20, 2026
bbc8ad1
fix: always confirm delete of deployed remote agents, fix deployed/ru…
tlongwell-block Mar 20, 2026
ab145a9
fix: lazy owner resolution on !shutdown, enforce required provider co…
tlongwell-block Mar 20, 2026
c728072
Merge origin/main into feat/remote-agent-providers
tlongwell-block Mar 20, 2026
cf8a473
fix: reorder token mint, enforce shutdown scopes, normalize start_on_…
tlongwell-block Mar 20, 2026
1d7e24b
fix: scope validation before ownership, enforce on re-mint, fix sort …
tlongwell-block Mar 21, 2026
1c8b25a
fix: fail-closed scope enforcement, admin re-mint scopes, no pre-vali…
tlongwell-block Mar 21, 2026
efdd814
fix: ownership only on explicit owner_pubkey, deployed agents don't t…
tlongwell-block Mar 21, 2026
808a0b3
fix: block provider-mode submit until probe succeeds
tlongwell-block Mar 21, 2026
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
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions crates/sprout-acp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,32 @@ async fn main() -> Result<()> {
}
}

// ── Step 2d: Query agent owner (with retry) ─────────────────────────────
// Owner lookup is critical for !shutdown — a transient failure here would
// permanently disable remote shutdown for this process. Retry a few times
// with backoff so a brief relay hiccup doesn't leave us uncontrollable.
// Owner lookup: try at startup, but if it fails the shutdown handler will
// retry lazily when a candidate !shutdown message arrives. This means a
// relay outage during startup doesn't permanently disable remote shutdown.
let mut owner_pubkey: Option<String> = {
let profile_url = format!("/api/users/{pubkey_hex}/profile");
match rest_client_for_presence.get_json(&profile_url).await {
Ok(v) => v
.get("agent_owner_pubkey")
.and_then(|v| v.as_str())
.map(String::from),
Err(e) => {
tracing::warn!("startup owner lookup failed (will retry lazily): {e}");
None
}
}
};
if let Some(ref owner) = owner_pubkey {
tracing::info!("agent owner: {owner}");
} else {
tracing::info!("no agent owner set at startup — will resolve lazily on !shutdown");
}

// ── Step 3: Discover channels and build subscription rules ────────────────
let channel_info_map = relay
.discover_channels()
Expand Down Expand Up @@ -432,6 +458,54 @@ async fn main() -> Result<()> {
tracing::debug!(channel_id = %sprout_event.channel_id, "dropping self-authored event");
continue;
}

// ── Shutdown command handling ─────────────────────
// Check: kind:9, content "!shutdown", from owner, mentions THIS agent.
let is_shutdown = kind_u32 == KIND_STREAM_MESSAGE
&& sprout_event.event.content.trim() == "!shutdown"
&& sprout_event.event.tags.iter().any(|t| {
t.as_slice().first().map(|s| s.as_str()) == Some("p")
&& t.as_slice().get(1).map(|s| s.as_str()) == Some(pubkey_hex.as_str())
});
if is_shutdown {
// Lazy owner resolution: if we don't have the owner
// yet (startup lookup failed), try now. This ensures
// a relay outage during startup doesn't permanently
// disable remote shutdown.
if owner_pubkey.is_none() {
let profile_url = format!("/api/users/{pubkey_hex}/profile");
match rest_client_for_presence.get_json(&profile_url).await {
Ok(v) => {
owner_pubkey = v
.get("agent_owner_pubkey")
.and_then(|v| v.as_str())
.map(String::from);
if let Some(ref o) = owner_pubkey {
tracing::info!("lazy owner resolution succeeded: {o}");
}
}
Err(e) => {
tracing::warn!("lazy owner lookup failed: {e}");
}
}
}
if let Some(ref owner) = owner_pubkey {
if sprout_event.event.pubkey.to_hex() == *owner {
tracing::info!(
channel_id = %sprout_event.channel_id,
sender = %sprout_event.event.pubkey.to_hex(),
"shutdown command from owner — exiting gracefully"
);
let _ = shutdown_tx.send(());
continue;
}
}
// Not from owner — fall through to normal prompt handling.
// Don't drop it — it's a regular message that happens to
// contain "!shutdown" from a non-owner.
}
// ── End shutdown command handling ──────────────────

let matched = filter::match_event(&sprout_event.event, sprout_event.channel_id, &rules, &pubkey_hex).await;
let prompt_tag = match matched {
Some(m) => m.prompt_tag,
Expand Down
55 changes: 50 additions & 5 deletions crates/sprout-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,63 @@ async fn mint_token(

let pubkey_bytes = pubkey.serialize().to_vec();

db.ensure_user(&pubkey_bytes).await?;
// ── Enforce shutdown-required scopes (before any DB writes) ─────────────
// Two triggers, same as the relay path:
// 1. Explicit --owner-pubkey (bootstrap mint)
// 2. Agent already has an owner in the DB (re-mint must preserve controllability)
// Fail closed: DB lookup error → assume owned → enforce scopes.
let has_existing_owner = match db.get_agent_channel_policy(&pubkey_bytes).await {
Ok(Some((_, Some(_)))) => true,
Ok(_) => false,
Err(e) => {
eprintln!("warning: owner lookup failed (assuming owned): {e}");
true // fail closed
}
};
if owner_pubkey.is_some() || has_existing_owner {
let required = [
"users:read",
"messages:read",
"messages:write",
"channels:read",
];
for r in &required {
if !scopes.iter().any(|s| s == r) {
anyhow::bail!("owned agents require the '{r}' scope for agent controllability");
}
}
}

// Set agent owner if --owner-pubkey was provided
if let Some(ref owner_hex) = owner_pubkey {
// ── Validate owner_pubkey (before any DB writes) ─────────────────────────
let validated_owner = if let Some(ref owner_hex) = owner_pubkey {
let owner_bytes =
hex::decode(owner_hex).map_err(|e| anyhow::anyhow!("invalid owner pubkey hex: {e}"))?;
if owner_bytes.len() != 32 {
anyhow::bail!("owner pubkey must be 32 bytes (64 hex chars)");
}
// Ensure owner's user row exists (FK constraint requires it)
Some(owner_bytes)
} else {
None
};

// ── DB writes (all validation passed) ────────────────────────────────────
db.ensure_user(&pubkey_bytes).await?;

if let Some(owner_bytes) = validated_owner {
db.ensure_user(&owner_bytes).await?;
db.set_agent_owner(&pubkey_bytes, &owner_bytes).await?;
let was_set = db.set_agent_owner(&pubkey_bytes, &owner_bytes).await?;
if !was_set {
let existing = db
.get_agent_channel_policy(&pubkey_bytes)
.await?
.and_then(|(_, owner)| owner);
if existing.as_deref() != Some(owner_bytes.as_slice()) {
anyhow::bail!(
"agent already has a different owner — refusing to mint token for non-owner"
);
}
eprintln!("note: agent already owned by the requested pubkey — proceeding");
}
}

let raw_token = generate_token();
Expand Down
4 changes: 4 additions & 0 deletions crates/sprout-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ pub const KIND_TYPING_INDICATOR: u32 = 20002;

// Stream messaging
/// NIP-29 group chat message kind. V1 used kind:10001 (replaceable range — wrong), then 40001.
///
/// Agent shutdown convention: the agent's owner sends a kind:9 message with content
/// `"!shutdown"` and a `#p` tag mentioning the agent. The harness exits gracefully.
/// This is a convention, not a new event kind — uses regular stream messages.
pub const KIND_STREAM_MESSAGE: u32 = 9;
/// V1 used kind:10002 (replaceable range — wrong).
pub const KIND_STREAM_MESSAGE_V2: u32 = 40002;
Expand Down
29 changes: 21 additions & 8 deletions crates/sprout-db/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,15 @@ async fn get_channel_tx(
row_to_channel_record(row)
}

/// A channel entry returned as part of a bot member record.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct BotChannelEntry {
/// Channel display name.
pub name: String,
/// Channel UUID (as string from the DB).
pub id: String,
}

/// Bot member record — a user with role=bot, with their channel memberships aggregated.
#[derive(Debug, Clone)]
pub struct BotMemberRecord {
Expand All @@ -657,8 +666,8 @@ pub struct BotMemberRecord {
pub agent_type: Option<String>,
/// Optional JSON capabilities descriptor.
pub capabilities: Option<serde_json::Value>,
/// Comma-separated channel names (from string_agg).
pub channel_names: String,
/// Channel entries with both name and UUID, from json_agg.
pub channels: Vec<BotChannelEntry>,
}

/// User record for bulk lookup.
Expand Down Expand Up @@ -747,15 +756,16 @@ pub async fn get_accessible_channels(
.collect()
}

/// Returns all bot-role members with their aggregated channel names.
/// Returns all bot-role members with their channel memberships.
///
/// Channel names are returned as a comma-separated string from string_agg.
/// Channels are returned as a JSON array of `{name, id}` objects via `json_agg`,
/// preserving the 1:1 name↔UUID pairing. No separate string_agg ordering issues.
/// Members with no active channel memberships are excluded (INNER JOIN on channels).
pub async fn get_bot_members(pool: &PgPool) -> Result<Vec<BotMemberRecord>> {
let rows = sqlx::query(
r#"
SELECT cm.pubkey, u.display_name, u.agent_type, u.capabilities,
string_agg(DISTINCT c.name, ',' ORDER BY c.name) AS channel_names
COALESCE(json_agg(DISTINCT jsonb_build_object('name', c.name, 'id', c.id::text)), '[]') AS channels_json
FROM channel_members cm
LEFT JOIN users u ON cm.pubkey = u.pubkey
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL
Expand All @@ -770,14 +780,17 @@ pub async fn get_bot_members(pool: &PgPool) -> Result<Vec<BotMemberRecord>> {
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let capabilities: Option<serde_json::Value> = row.try_get("capabilities")?;
let channels_json: serde_json::Value = row
.try_get::<serde_json::Value, _>("channels_json")
.unwrap_or(serde_json::Value::Array(vec![]));
let channels: Vec<BotChannelEntry> =
serde_json::from_value(channels_json).unwrap_or_default();
out.push(BotMemberRecord {
pubkey: row.try_get("pubkey")?,
display_name: row.try_get("display_name")?,
agent_type: row.try_get("agent_type")?,
capabilities,
channel_names: row
.try_get::<Option<String>, _>("channel_names")?
.unwrap_or_default(),
channels,
});
}
Ok(out)
Expand Down
5 changes: 3 additions & 2 deletions crates/sprout-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,8 +491,9 @@ impl Db {
user::search_users(&self.pool, query, limit).await
}

/// Set the owner pubkey for an agent user.
pub async fn set_agent_owner(&self, agent_pubkey: &[u8], owner_pubkey: &[u8]) -> Result<()> {
/// Atomically set agent owner — only if no owner is currently assigned.
/// Returns Ok(true) if set, Ok(false) if an owner already exists.
pub async fn set_agent_owner(&self, agent_pubkey: &[u8], owner_pubkey: &[u8]) -> Result<bool> {
user::set_agent_owner(&self.pool, agent_pubkey, owner_pubkey).await
}

Expand Down
Loading
Loading