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]>
285 lines
9.5 KiB
Rust
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());
|
|
}
|