Full-depth rename per the approved plan; the 'claw' product vocabulary (claws, /claws routes, clawId, Claw Chat) stays — it is now the brand. - Display brand: Clawmates (manifest, titles, hero, login/rail logo 'clawmates'); default host app.clawmates.work; registry ghcr.io/clawmates - Crates tc-* -> cm-* (16 crates + all imports); binaries clawmates-server/broker/bundler; images clawmates/*; env prefix CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config clawmates.toml; helm chart deploy/helm/clawmates with clawmates-* resources; db names clawmates*; sockets /run/clawmates; cookie cm_session; kind cluster clawmates-test; seccomp node profile clawmates-agent-profile.json - All 9 Playwright brand assertions updated in lockstep; historical spec document left untouched as the only remaining 'TeamClaw' - Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared test server clawmates-test-pg, kind cluster recreated with image + profile, compose images rebuilt under clawmates/* Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and the clean-room install rehearsal serving the clawmates login page from a signed bundle of the rebuilt images. Co-Authored-By: Claude Fable 5 <[email protected]>
208 lines
6.8 KiB
Rust
208 lines
6.8 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,
|
|
broker_socket: Some(socket.clone()),
|
|
slack_base_url: "http://127.0.0.1:1".into(), // never reached here
|
|
},
|
|
);
|
|
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");
|
|
}
|