Files
clawmates/crates/cm-secrets/tests/broker.rs
T
Omar SobhandClaude Fable 5 add4f79fed Rebrand: TeamClaw -> Clawmates (clawmates.work)
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]>
2026-06-10 12:31:25 -05:00

285 lines
9.5 KiB
Rust

//! Broker tests against the real wire: a real unix socket, real Postgres,
//! real crypto, and a real local HTTP receiver standing in for the external
//! service (its base URL is configuration — the same mechanism air-gapped
//! installs use).
use std::sync::Arc;
use axum::extract::State;
use axum::routing::post;
use cm_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, GatedCategory, Role, User, UserId, Workspace,
WorkspaceId,
};
use cm_safety::{approvals, Decision, NewApproval};
use cm_secrets::{BrokerClient, BrokerError, BrokerServer, FileKey};
use serde_json::json;
use tokio::sync::Mutex;
struct Seed {
workspace: Workspace,
owner: User,
agent: Agent,
run_id: uuid::Uuid,
}
async fn seeded(pool: &sqlx::PgPool) -> Seed {
let workspace = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(pool, &workspace)
.await
.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: workspace.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();
let agent = Agent {
id: AgentId::new(),
workspace_id: workspace.id,
name: "Scout".into(),
job_title: "Analyst".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: owner.id,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.unwrap();
let session = cm_db::repo::sessions::create(pool, agent.id, workspace.id, "Chat")
.await
.unwrap();
let run_id = cm_db::repo::runs::create(pool, session.id).await.unwrap();
Seed {
workspace,
owner,
agent,
run_id,
}
}
/// A real HTTP receiver that records the Authorization headers it sees.
async fn spawn_receiver() -> (String, Arc<Mutex<Vec<String>>>) {
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let state = seen.clone();
let app =
axum::Router::new()
.route(
"/hook",
post(
|State(seen): State<Arc<Mutex<Vec<String>>>>,
headers: axum::http::HeaderMap| async move {
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
seen.lock().await.push(auth);
axum::Json(json!({"received": true}))
},
),
)
.with_state(state);
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();
});
(format!("http://{addr}"), seen)
}
async fn broker_pair(pool: sqlx::PgPool, dir: &std::path::Path) -> BrokerClient {
let key_path = dir.join("broker.key");
FileKey::generate(&key_path).unwrap();
let key = FileKey::load(&key_path).unwrap();
// Unix socket paths are limited to ~104 bytes on macOS; keep it short.
// Use the RANDOM tail of the uuid — the v7 prefix is a timestamp and
// collides across tests started in the same millisecond.
let short = uuid::Uuid::now_v7().simple().to_string();
let socket = std::path::PathBuf::from(format!("/tmp/tcb-{}.sock", &short[short.len() - 12..]));
let server = BrokerServer::new(pool, key, socket.clone());
tokio::spawn(async move {
server.serve().await.unwrap();
});
// The socket appears asynchronously.
for _ in 0..50 {
if socket.exists() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
BrokerClient::connect(&socket).await.unwrap()
}
async fn approved_approval(pool: &sqlx::PgPool, seed: &Seed) -> uuid::Uuid {
let approval = approvals::create(
pool,
NewApproval {
workspace_id: seed.workspace.id,
run_id: seed.run_id,
session_key: "agent:x-claw-0:session:y:z".into(),
action_type: "http.request".into(),
category: GatedCategory::OutboundMessage,
payload: json!({}),
preview: json!({}),
requested_by_agent: seed.agent.id,
taint_sources: vec![],
expires_at: None,
},
)
.await
.unwrap();
approvals::decide(pool, approval.id, seed.owner.id, Decision::Approve)
.await
.unwrap();
approval.id
}
#[tokio::test]
async fn secrets_are_encrypted_at_rest_and_never_returned() {
let pool = cm_testkit::test_pool().await;
let seed = seeded(&pool).await;
let dir = std::env::temp_dir().join(format!("tc-broker-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let mut client = broker_pair(pool.clone(), &dir).await;
let secret_id = client
.store_secret(seed.workspace.id, "api_key", "sk-super-secret-token")
.await
.unwrap();
// At rest: ciphertext only, never the plaintext bytes.
let ciphertext: Vec<u8> = sqlx::query_scalar("SELECT ciphertext FROM secrets WHERE id = $1")
.bind(secret_id)
.fetch_one(&pool)
.await
.unwrap();
let raw = String::from_utf8_lossy(&ciphertext);
assert!(!raw.contains("sk-super-secret-token"));
// No protocol operation returns plaintext — metadata only.
let kind = client.secret_kind(secret_id).await.unwrap();
assert_eq!(kind, "api_key");
}
#[tokio::test]
async fn capability_requires_an_unconsumed_grant() {
let pool = cm_testkit::test_pool().await;
let seed = seeded(&pool).await;
let dir = std::env::temp_dir().join(format!("tc-broker-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let mut client = broker_pair(pool.clone(), &dir).await;
let (receiver_url, seen) = spawn_receiver().await;
let secret_id = client
.store_secret(seed.workspace.id, "api_key", "sk-live-12345")
.await
.unwrap();
// Pending approval (no grant yet): the broker refuses outright.
let pending = approvals::create(
&pool,
NewApproval {
workspace_id: seed.workspace.id,
run_id: seed.run_id,
session_key: "k".into(),
action_type: "http.request".into(),
category: GatedCategory::OutboundMessage,
payload: json!({}),
preview: json!({}),
requested_by_agent: seed.agent.id,
taint_sources: vec![],
expires_at: None,
},
)
.await
.unwrap();
let refused = client
.invoke_http(
pending.id,
secret_id,
&format!("{receiver_url}/hook"),
json!({}),
)
.await;
assert!(matches!(refused, Err(BrokerError::GrantRefused)));
assert!(seen.lock().await.is_empty(), "nothing may execute");
// Approved: the call executes WITH the credential injected; the
// credential itself never crosses back over the socket.
let approval_id = approved_approval(&pool, &seed).await;
let status = client
.invoke_http(
approval_id,
secret_id,
&format!("{receiver_url}/hook"),
json!({}),
)
.await
.unwrap();
assert_eq!(status, 200);
let observed = seen.lock().await;
assert_eq!(observed.as_slice(), ["Bearer sk-live-12345"]);
drop(observed);
// The grant is single-use: replay refused, no second call.
let replay = client
.invoke_http(
approval_id,
secret_id,
&format!("{receiver_url}/hook"),
json!({}),
)
.await;
assert!(matches!(replay, Err(BrokerError::GrantRefused)));
assert_eq!(seen.lock().await.len(), 1);
}
#[tokio::test]
async fn non_http_urls_are_rejected() {
let pool = cm_testkit::test_pool().await;
let seed = seeded(&pool).await;
let dir = std::env::temp_dir().join(format!("tc-broker-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let mut client = broker_pair(pool.clone(), &dir).await;
let secret_id = client
.store_secret(seed.workspace.id, "api_key", "sk-x")
.await
.unwrap();
let approval_id = approved_approval(&pool, &seed).await;
let refused = client
.invoke_http(approval_id, secret_id, "file:///etc/passwd", json!({}))
.await;
assert!(matches!(refused, Err(BrokerError::Invalid(_))));
}
#[test]
fn key_files_round_trip_and_reject_corruption() {
let dir = std::env::temp_dir().join(format!("tc-key-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("broker.key");
FileKey::generate(&path).unwrap();
let key = FileKey::load(&path).unwrap();
let sealed = key.seal(b"attack at dawn").unwrap();
assert_ne!(sealed.ciphertext, b"attack at dawn");
let opened = key.open(&sealed).unwrap();
assert_eq!(opened, b"attack at dawn");
// Bit-flip is detected (AEAD), not silently decrypted.
let mut tampered = sealed;
tampered.ciphertext[0] ^= 0xff;
assert!(key.open(&tampered).is_err());
}