Files
clawmates/crates/cm-api/tests/slack_inbound.rs
T
Omar SobhandClaude Opus 4.8 e61724ff82 Agent computer: terminal (tmux + drives + tabs), Obsidian vault, UI polish
Terminal app (xterm ⇄ WebSocket ⇄ per-agent themed container):
- zsh + oh-my-zsh + powerlevel10k image (agent-terminal), runs as uid 65532 to
  share read-write ownership of the file-drive volume with the server.
- Interactive PTY in cm-sandbox (bollard exec tty/attach + resize) + a
  TerminalManager; ticket-authed WS bridge routed straight to the backend via a
  Traefik PathRegexp(/ws) rule. MOTD greets the user by name.
- tmux resumable sessions; multi-tab (one tmux session per tab, same container),
  drag-to-reorder, rename, and a Save that persists named tabs to the server
  (terminal_tabs, migration 0014) so they survive logout / a new device.
- Files drives mounted per-agent (subpath) at ~/drives/{documents,received,
  shared}; a reconciler keeps the Files app's index in sync with terminal writes.
  Storage moved to a shared `filedata` volume (CLAWMATES_STORAGE__DATA_DIR).

Obsidian vault (a markdown "second brain" per agent):
- New `vault` FileDrive (migration 0015) mounted into the terminal at ~/obsidian;
  a file-content read route; a purple Obsidian tile + a vault viewer app.

Computer UI:
- Draggable computer-panel width (min = phone preset) keeping the size presets.
- Green Terminal glyph, "Claw Chat" → "Chat", colored gradient-outline app icons.
- Agent page: avatar↔activity-grid spacing + larger, uniform section fonts with
  colored section-tinted tag chips.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-23 16:52:35 -07:00

210 lines
6.9 KiB
Rust

//! Inbound Slack @mentions: signature verified BY the broker, a verified
//! mention drives a real run, and the agent's reply is gated as usual.
use std::sync::Arc;
use std::time::Duration;
use cm_api::AppState;
use cm_auth::AuthService;
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
use cm_llm::ScriptedProvider;
use cm_runtime::{Runtime, RuntimeConfig};
use cm_secrets::{BrokerServer, FileKey};
use hmac::{Hmac, Mac};
use serde_json::{json, Value};
const SCENARIOS: &str = r##"
[[scenario]]
marker = "[[scenario:mention]]"
[[scenario.turns]]
events = [
{ type = "tool_use", name = "slack.post", input = { channel = "#general", text = "On it!" } },
]
[[scenario.turns]]
events = [
{ type = "text", text = " Replied in Slack." },
]
"##;
fn sign(secret: &str, timestamp: &str, body: &str) -> String {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("v0:{timestamp}:{body}").as_bytes());
format!("v0={}", hex::encode(mac.finalize().into_bytes()))
}
async fn spawn_broker(pool: sqlx::PgPool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("tc-in-{}", uuid::Uuid::now_v7().simple()));
std::fs::create_dir_all(&dir).unwrap();
let key_path = dir.join("broker.key");
FileKey::generate(&key_path).unwrap();
let key = FileKey::load(&key_path).unwrap();
let short = uuid::Uuid::now_v7().simple().to_string();
let socket = std::path::PathBuf::from(format!("/tmp/tci-{}.sock", &short[short.len() - 12..]));
let server = BrokerServer::new(pool, key, socket.clone());
tokio::spawn(async move {
server.serve().await.unwrap();
});
for _ in 0..50 {
if socket.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
socket
}
#[tokio::test]
async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
let pool = cm_testkit::test_pool().await;
let socket = spawn_broker(pool.clone()).await;
let runtime = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig {
model: "scripted".into(),
max_tokens: 1024,
sandboxes: None,
browser: None,
terminals: None,
broker_socket: Some(socket.clone()),
slack_base_url: "http://127.0.0.1:1".into(), // never reached here
providers: Default::default(),
},
);
let app = cm_api::router(AppState::new(pool.clone(), runtime).with_broker(socket.clone()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base = format!("http://{addr}");
let client = reqwest::Client::new();
// Seed + connect Slack with a JSON secret (bot token + signing secret).
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
cm_db::repo::users::insert(&pool, &owner).await.unwrap();
AuthService::new(pool.clone())
.set_password(owner.id, "pw")
.await
.unwrap();
let token = client
.post(format!("{base}/api/auth/login"))
.json(&json!({"email": owner.email, "password": "pw"}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_owned();
let claw: Value = client
.post(format!("{base}/api/claws"))
.bearer_auth(&token)
.json(&json!({"name": "Scout", "job_title": "Analyst"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let claw_id = claw["id"].as_str().unwrap();
let signing_secret = "shh-signing";
let connect = client
.post(format!("{base}/api/apps/connect"))
.bearer_auth(&token)
.json(&json!({
"clawId": claw_id,
"provider": "slack",
"authType": "keys",
"secret": json!({"bot_token": "xoxb-in", "signing_secret": signing_secret}).to_string(),
}))
.send()
.await
.unwrap();
assert_eq!(connect.status(), 201);
// A forged signature is rejected outright.
let body = json!({
"type": "event_callback",
"event": {"type": "app_mention", "text": "summarize [[scenario:mention]]"}
})
.to_string();
let forged = client
.post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", "12345")
.header("x-slack-signature", "v0=deadbeef")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(forged.status(), 401);
// url_verification handshake echoes the challenge when signed.
let challenge_body = json!({"type": "url_verification", "challenge": "abc123"}).to_string();
let challenge = client
.post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", "12345")
.header(
"x-slack-signature",
sign(signing_secret, "12345", &challenge_body),
)
.body(challenge_body.clone())
.send()
.await
.unwrap();
assert_eq!(challenge.status(), 200);
assert_eq!(
challenge.json::<Value>().await.unwrap()["challenge"],
"abc123"
);
// A properly signed mention starts a run in the '💬 Slack' session and
// the agent's reply is intercepted by the approval gate.
let mention = client
.post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", "12345")
.header("x-slack-signature", sign(signing_secret, "12345", &body))
.body(body)
.send()
.await
.unwrap();
assert_eq!(mention.status(), 200);
let agent_id = cm_domain::AgentId::from(claw_id.parse::<uuid::Uuid>().unwrap());
let mut gated = false;
for _ in 0..100 {
let sessions = cm_db::repo::sessions::list_by_agent(&pool, agent_id)
.await
.unwrap();
if sessions.iter().any(|s| s.title == "💬 Slack") {
let pending = cm_safety::approvals::list_pending(&pool, ws.id)
.await
.unwrap();
if pending.len() == 1 && pending[0].action_type == "slack.post" {
gated = true;
break;
}
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(gated, "mention must drive a run whose reply is gated");
}