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
8 changes: 1 addition & 7 deletions crates/sprout-db/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,13 +340,7 @@ pub async fn add_member(
DbError::InvalidData(format!("invalid role in database: {inviter_role_str}"))
})?;

if !inviter_role.is_elevated() {
return Err(DbError::AccessDenied(
"inviter must be owner or admin".to_string(),
));
}

// Only owners/admins may grant elevated roles (already verified above — kept for clarity).
// Any member can invite others, but only owners/admins may grant elevated roles.
if role.is_elevated() && !inviter_role.is_elevated() {
return Err(DbError::AccessDenied(
"only owners/admins may grant elevated roles".to_string(),
Expand Down
23 changes: 19 additions & 4 deletions crates/sprout-relay/src/handlers/side_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,14 +183,29 @@ pub async fn validate_admin_event(
return Err(anyhow::anyhow!("invalid role: {role_str}"));
}

// PUT_USER: open channels allow any authenticated user; private requires owner/admin.
// Policy check applies to both open and private channels.
// PUT_USER: open channels allow any authenticated user; private channels
// require the actor to be an existing member (any role can invite).
if channel.visibility == "private" {
let members = state.db.get_members(channel_id).await?;
let actor_member = members.iter().find(|m| m.pubkey == actor_bytes);
match actor_member {
Some(m) if m.role == "owner" || m.role == "admin" => {}
_ => return Err(anyhow::anyhow!("actor not authorized")),
Some(_) => {}
None => return Err(anyhow::anyhow!("actor not authorized")),
}

// Only owners/admins may grant elevated roles.
let role: sprout_db::channel::MemberRole = role_str.parse().unwrap();
if role.is_elevated() {
let actor_role: sprout_db::channel::MemberRole = actor_member
.unwrap()
.role
.parse()
.unwrap_or(sprout_db::channel::MemberRole::Member);
if !actor_role.is_elevated() {
return Err(anyhow::anyhow!(
"only owners/admins may grant elevated roles"
));
}
}
}

Expand Down
215 changes: 215 additions & 0 deletions crates/sprout-test-client/tests/e2e_relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1789,3 +1789,218 @@ async fn test_membership_notification_mixed_filter_rejected() {

client.disconnect().await.expect("disconnect");
}

// ─── Private channel membership permission tests ───────────────────────────────

/// Create a private channel over WebSocket and return the channel UUID.
async fn create_private_channel_ws(client: &mut SproutTestClient, keys: &Keys) -> String {
let channel_uuid = uuid::Uuid::new_v4().to_string();
let channel_name = format!("relay-e2e-private-{}", channel_uuid);

let event = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid]).unwrap(),
Tag::parse(["name", &channel_name]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "private"]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();

let ok = client
.send_event(event)
.await
.expect("create private channel");
assert!(
ok.accepted,
"private channel creation failed: {}",
ok.message
);
channel_uuid
}

/// Submit a kind:9000 PUT_USER event over WebSocket.
async fn add_member_ws(
client: &mut SproutTestClient,
channel_id: &str,
target_pubkey_hex: &str,
signer: &Keys,
) -> (bool, String) {
let h_tag = Tag::parse(["h", channel_id]).unwrap();
let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap();
let event = EventBuilder::new(Kind::Custom(9000), "")
.tags([h_tag, p_tag])
.sign_with_keys(signer)
.unwrap();

let ok = client.send_event(event).await.expect("send PUT_USER event");
(ok.accepted, ok.message)
}

/// Submit a kind:9000 PUT_USER event with a role tag over WebSocket.
async fn add_member_with_role_ws(
client: &mut SproutTestClient,
channel_id: &str,
target_pubkey_hex: &str,
role: &str,
signer: &Keys,
) -> (bool, String) {
let h_tag = Tag::parse(["h", channel_id]).unwrap();
let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap();
let role_tag = Tag::parse(["role", role]).unwrap();
let event = EventBuilder::new(Kind::Custom(9000), "")
.tags([h_tag, p_tag, role_tag])
.sign_with_keys(signer)
.unwrap();

let ok = client
.send_event(event)
.await
.expect("send PUT_USER event with role");
(ok.accepted, ok.message)
}

/// Any member of a private channel can invite another user (Slack model).
#[tokio::test]
#[ignore]
async fn test_private_channel_any_member_can_invite() {
let url = relay_url();
let owner_keys = Keys::generate();
let member_keys = Keys::generate();
let invitee_keys = Keys::generate();

// Connect as owner and create a private channel.
let mut owner_client = SproutTestClient::connect(&url, &owner_keys)
.await
.expect("connect as owner");
let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await;

// Owner adds member_keys as a regular member.
let (accepted, msg) = add_member_ws(
&mut owner_client,
&channel_id,
&member_keys.public_key().to_hex(),
&owner_keys,
)
.await;
assert!(accepted, "owner should add member, got: {msg}");

// Connect as the regular member.
let mut member_client = SproutTestClient::connect(&url, &member_keys)
.await
.expect("connect as member");

// Regular member invites a third user — this should succeed.
let (accepted, msg) = add_member_ws(
&mut member_client,
&channel_id,
&invitee_keys.public_key().to_hex(),
&member_keys,
)
.await;
assert!(
accepted,
"regular member should be able to invite to private channel, got: {msg}"
);

owner_client.disconnect().await.expect("disconnect owner");
member_client.disconnect().await.expect("disconnect member");
}

/// A non-member cannot invite someone to a private channel.
#[tokio::test]
#[ignore]
async fn test_private_channel_non_member_cannot_invite() {
let url = relay_url();
let owner_keys = Keys::generate();
let outsider_keys = Keys::generate();
let target_keys = Keys::generate();

// Owner creates a private channel.
let mut owner_client = SproutTestClient::connect(&url, &owner_keys)
.await
.expect("connect as owner");
let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await;

// Connect as outsider (not a member of the channel).
let mut outsider_client = SproutTestClient::connect(&url, &outsider_keys)
.await
.expect("connect as outsider");

// Outsider tries to add someone — should be rejected.
let (accepted, msg) = add_member_ws(
&mut outsider_client,
&channel_id,
&target_keys.public_key().to_hex(),
&outsider_keys,
)
.await;
assert!(
!accepted,
"non-member should NOT be able to invite to private channel, but it was accepted"
);
assert!(
msg.contains("not authorized") || msg.contains("not a channel member"),
"rejection should mention authorization or membership, got: {msg}"
);

owner_client.disconnect().await.expect("disconnect owner");
outsider_client
.disconnect()
.await
.expect("disconnect outsider");
}

/// Regular members cannot grant elevated roles (owner/admin) in private channels.
#[tokio::test]
#[ignore]
async fn test_private_channel_member_cannot_grant_admin() {
let url = relay_url();
let owner_keys = Keys::generate();
let member_keys = Keys::generate();
let target_keys = Keys::generate();

// Owner creates a private channel and adds a regular member.
let mut owner_client = SproutTestClient::connect(&url, &owner_keys)
.await
.expect("connect as owner");
let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await;

let (accepted, msg) = add_member_ws(
&mut owner_client,
&channel_id,
&member_keys.public_key().to_hex(),
&owner_keys,
)
.await;
assert!(accepted, "owner should add member, got: {msg}");

// Connect as the regular member.
let mut member_client = SproutTestClient::connect(&url, &member_keys)
.await
.expect("connect as member");

// Regular member tries to add someone with admin role — should fail.
let (accepted, msg) = add_member_with_role_ws(
&mut member_client,
&channel_id,
&target_keys.public_key().to_hex(),
"admin",
&member_keys,
)
.await;
assert!(
!accepted,
"regular member should NOT grant admin role, but it was accepted"
);
assert!(
msg.contains("elevated")
|| msg.contains("owner")
|| msg.contains("admin")
|| msg.contains("grant"),
"rejection should mention elevated roles, got: {msg}"
);

owner_client.disconnect().await.expect("disconnect owner");
member_client.disconnect().await.expect("disconnect member");
}
7 changes: 6 additions & 1 deletion desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
load_managed_agents, managed_agent_avatar_url, missing_command_message,
normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes,
AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse,
DEFAULT_MCP_COMMAND,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
Expand Down Expand Up @@ -191,7 +192,11 @@ pub async fn update_managed_agent(
record.agent_args = agent_args;
}
if let Some(mcp_command) = input.mcp_command {
record.mcp_command = mcp_command;
record.mcp_command = if mcp_command.trim().is_empty() {
DEFAULT_MCP_COMMAND.to_string()
} else {
mcp_command
};
}
if let Some(env_vars) = input.env_vars {
crate::managed_agents::validate_user_env_keys(&env_vars)?;
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ pub const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server";
pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320;
/// 1 hour — absolute wall-clock safety cap per turn.
pub const DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS: u64 = 3600;
pub const DEFAULT_AGENT_PARALLELISM: u32 = 3;
pub const DEFAULT_AGENT_PARALLELISM: u32 = 24;

fn default_agent_parallelism() -> u32 {
DEFAULT_AGENT_PARALLELISM
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/agents/ui/CreateAgentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function CreateAgentDialog({
const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true);
const [startOnAppLaunch, setStartOnAppLaunch] = React.useState(true);
const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("320");
const [parallelism, setParallelism] = React.useState("3");
const [parallelism, setParallelism] = React.useState("24");
const [systemPrompt, setSystemPrompt] = React.useState("");
const [envVars, setEnvVars] = React.useState<EnvVarsValue>({});
const [selectedProviderId, setSelectedProviderId] =
Expand Down Expand Up @@ -218,7 +218,7 @@ export function CreateAgentDialog({
setMcpCommand("sprout-mcp-server");
setMcpToolsets("");
setTurnTimeoutSeconds("320");
setParallelism("3");
setParallelism("24");
setSystemPrompt("");
setEnvVars({});
setSelectedProviderId("custom");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function CreateAgentBasicsFields({
<Input
aria-describedby="help-agent-name"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
data-testid="agent-name-input"
id="agent-name"
Expand Down Expand Up @@ -150,6 +151,7 @@ export function CreateAgentRuntimeFields({
</label>
<Input
aria-describedby="help-agent-relay-url"
autoComplete="off"
id="agent-relay-url"
onChange={(event) => onRelayUrlChange(event.target.value)}
placeholder="Leave blank to use the desktop relay"
Expand All @@ -170,6 +172,7 @@ export function CreateAgentRuntimeFields({
</label>
<Input
aria-describedby="help-agent-acp-command"
autoComplete="off"
id="agent-acp-command"
onChange={(event) => onAcpCommandChange(event.target.value)}
value={acpCommand}
Expand All @@ -194,6 +197,7 @@ export function CreateAgentRuntimeFields({
</label>
<Input
aria-describedby="help-agent-runtime-command"
autoComplete="off"
id="agent-runtime-command"
onChange={(event) => onAgentCommandChange(event.target.value)}
value={agentCommand}
Expand All @@ -215,6 +219,7 @@ export function CreateAgentRuntimeFields({
</label>
<Input
aria-describedby="help-agent-runtime-args"
autoComplete="off"
id="agent-runtime-args"
onChange={(event) => onAgentArgsChange(event.target.value)}
placeholder="Comma-separated"
Expand All @@ -234,6 +239,7 @@ export function CreateAgentRuntimeFields({
</label>
<Input
aria-describedby="help-agent-mcp-command"
autoComplete="off"
id="agent-mcp-command"
onChange={(event) => onMcpCommandChange(event.target.value)}
value={mcpCommand}
Expand All @@ -253,6 +259,7 @@ export function CreateAgentRuntimeFields({
</label>
<Input
aria-describedby="help-agent-timeout"
autoComplete="off"
id="agent-timeout"
onChange={(event) => onTurnTimeoutChange(event.target.value)}
placeholder="300"
Expand All @@ -269,6 +276,7 @@ export function CreateAgentRuntimeFields({
</label>
<Input
aria-describedby="help-agent-parallelism"
autoComplete="off"
data-testid="agent-parallelism-input"
id="agent-parallelism"
inputMode="numeric"
Expand All @@ -294,6 +302,7 @@ export function CreateAgentRuntimeFields({
MCP toolsets
</label>
<Input
autoComplete="off"
id="agent-mcp-toolsets"
onChange={(event) => onMcpToolsetsChange(event.target.value)}
placeholder="default,canvas,forums,dms,media"
Expand Down
Loading