P4 core: broker-held app connections + gated, broker-executed Slack posting

- app_connections repo; POST /api/apps/connect (keys/basic): the credential
  goes to the secret broker over its socket and only the encrypted ref lands
  in the row; disconnect endpoint; /api/apps directory merged with live
  connection status; audit rows for connect/disconnect
- Broker protocol: InvokeHttp carries a JSON body
- slack.post tool (SendsExternally -> gated): marked broker_executed — the
  runtime skips its own grant consumption and the BROKER independently
  verifies + consumes the single-use grant, then calls Slack with the bot
  token injected; the runtime never sees the credential
- Config: [broker] socket_path + [slack] base_url; e2e harness spawns the
  real teamclaw-broker daemon and the server hosts an e2e-only /__slack sink
- SlackApp: Connection tab stores the token via the broker; connected state
- Integration test: blocked while pending -> approved -> sink received
  exactly one post with 'Bearer xoxb-test-token' -> grant replay refused
- E2E journey: connect Slack in the panel -> gated post card with preview ->
  sink empty while pending -> approve -> exactly one post, queue clear

133 Rust + 63 frontend tests + 21 Playwright journeys.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 05:51:19 -05:00
co-authored by Claude Fable 5
parent 1fd2c287f1
commit 000b9b3a4b
40 changed files with 1058 additions and 80 deletions
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app_connections\n (id, workspace_id, agent_id, provider, auth_type, status, secret_ref)\n VALUES ($1, $2, $3, $4, $5, 'connected', $6)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Text",
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "1330f85a91383ba749e6ff8d061eecefa93b9cd936e2fdc6af355067cf86bfcb"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app_connections SET status = 'disconnected' WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "697b497e7d6ba7a28a7ffed0901738d6e09bd884179c5fca930fabf7d358f74a"
}
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref\n FROM app_connections\n WHERE workspace_id = $1 AND (agent_id IS NULL OR agent_id = $2)\n AND status = 'connected'\n ORDER BY created_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "agent_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "provider",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "auth_type",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "secret_ref",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false,
false,
true,
false,
false,
false,
true
]
},
"hash": "cb04f633bbc54c878d59a717faf6e3c9376565ea1e3b79c55fd721b98c313b6d"
}
@@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref\n FROM app_connections\n WHERE workspace_id = $1 AND (agent_id IS NULL OR agent_id = $2)\n AND provider = $3 AND status = 'connected'\n ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "agent_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "provider",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "auth_type",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "secret_ref",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text"
]
},
"nullable": [
false,
false,
true,
false,
false,
false,
true
]
},
"hash": "fac227a443035c5f5acd5e09bdb64aea38509b2c3ae2b9d5dd3768dc609578b7"
}
Generated
+4
View File
@@ -2861,6 +2861,7 @@ dependencies = [
"tc-runtime", "tc-runtime",
"tc-safety", "tc-safety",
"tc-scheduler", "tc-scheduler",
"tc-secrets",
"tc-testkit", "tc-testkit",
"thiserror", "thiserror",
"time", "time",
@@ -2953,6 +2954,7 @@ name = "tc-runtime"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum",
"chrono", "chrono",
"croner", "croner",
"futures", "futures",
@@ -2964,6 +2966,7 @@ dependencies = [
"tc-files", "tc-files",
"tc-llm", "tc-llm",
"tc-safety", "tc-safety",
"tc-secrets",
"tc-testkit", "tc-testkit",
"tc-tools", "tc-tools",
"thiserror", "thiserror",
@@ -3074,6 +3077,7 @@ name = "teamclaw-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"serde_json",
"sqlx", "sqlx",
"tc-api", "tc-api",
"tc-auth", "tc-auth",
+1
View File
@@ -8,6 +8,7 @@ publish.workspace = true
[dependencies] [dependencies]
axum = "0.8" axum = "0.8"
serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
tc-api = { path = "../../tc-api" } tc-api = { path = "../../tc-api" }
tc-auth = { path = "../../tc-auth" } tc-auth = { path = "../../tc-auth" }
+29
View File
@@ -11,6 +11,35 @@ use tc_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId, AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
}; };
/// In e2e mode the server hosts a Slack-shaped sink so journeys can assert
/// exactly what the broker posted, without external infrastructure.
pub fn slack_sink_router() -> axum::Router {
use std::sync::Arc;
use tokio::sync::Mutex;
type Posts = Arc<Mutex<Vec<serde_json::Value>>>;
let posts: Posts = Arc::new(Mutex::new(Vec::new()));
axum::Router::new()
.route(
"/__slack/chat.postMessage",
axum::routing::post(
|axum::extract::State(posts): axum::extract::State<Posts>,
axum::Json(body): axum::Json<serde_json::Value>| async move {
posts.lock().await.push(body);
axum::Json(serde_json::json!({"ok": true}))
},
),
)
.route(
"/__slack/posts",
axum::routing::get(
|axum::extract::State(posts): axum::extract::State<Posts>| async move {
axum::Json(posts.lock().await.clone())
},
),
)
.with_state(posts)
}
pub const E2E_OWNER_EMAIL: &str = "[email protected]"; pub const E2E_OWNER_EMAIL: &str = "[email protected]";
pub const E2E_OWNER_PASSWORD: &str = "e2e-password"; pub const E2E_OWNER_PASSWORD: &str = "e2e-password";
+8 -1
View File
@@ -78,6 +78,8 @@ async fn run() -> Result<(), String> {
RuntimeConfig { RuntimeConfig {
model: config.llm.model.clone(), model: config.llm.model.clone(),
max_tokens: 4096, max_tokens: 4096,
broker_socket: Some(PathBuf::from(&config.broker.socket_path)),
slack_base_url: config.slack.base_url.clone(),
}, },
blob, blob,
); );
@@ -88,7 +90,12 @@ async fn run() -> Result<(), String> {
tc_scheduler::Scheduler::new(pool.clone(), runtime.clone()) tc_scheduler::Scheduler::new(pool.clone(), runtime.clone())
.spawn(std::time::Duration::from_secs(5)); .spawn(std::time::Duration::from_secs(5));
let app = tc_api::router(tc_api::AppState::new(pool, runtime)); let mut app = tc_api::router(
tc_api::AppState::new(pool, runtime).with_broker(PathBuf::from(&config.broker.socket_path)),
);
if e2e::enabled() {
app = app.merge(e2e::slack_sink_router());
}
let listener = tokio::net::TcpListener::bind(config.listen_addr) let listener = tokio::net::TcpListener::bind(config.listen_addr)
.await .await
.map_err(|e| format!("bind {} failed: {e}", config.listen_addr))?; .map_err(|e| format!("bind {} failed: {e}", config.listen_addr))?;
+1
View File
@@ -19,6 +19,7 @@ tc-domain = { path = "../tc-domain" }
tc-runtime = { path = "../tc-runtime" } tc-runtime = { path = "../tc-runtime" }
tc-safety = { path = "../tc-safety" } tc-safety = { path = "../tc-safety" }
tc-scheduler = { path = "../tc-scheduler" } tc-scheduler = { path = "../tc-scheduler" }
tc-secrets = { path = "../tc-secrets" }
thiserror = { workspace = true } thiserror = { workspace = true }
time = { workspace = true } time = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
+10
View File
@@ -18,6 +18,8 @@ pub struct AppState {
pub pool: PgPool, pub pool: PgPool,
pub auth: AuthService, pub auth: AuthService,
pub runtime: Runtime, pub runtime: Runtime,
/// Secret broker socket; connect flows refuse without it.
pub broker_socket: Option<std::path::PathBuf>,
} }
impl AppState { impl AppState {
@@ -27,8 +29,14 @@ impl AppState {
pool, pool,
auth, auth,
runtime, runtime,
broker_socket: None,
} }
} }
pub fn with_broker(mut self, socket: std::path::PathBuf) -> AppState {
self.broker_socket = Some(socket);
self
}
} }
pub fn router(state: AppState) -> Router { pub fn router(state: AppState) -> Router {
@@ -62,6 +70,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/openclaw/files", get(routes::files::openclaw_files)) .route("/api/openclaw/files", get(routes::files::openclaw_files))
.route("/api/shared-drive/files", get(routes::files::shared_files)) .route("/api/shared-drive/files", get(routes::files::shared_files))
.route("/api/apps", get(routes::apps::directory)) .route("/api/apps", get(routes::apps::directory))
.route("/api/apps/connect", post(routes::apps::connect))
.route("/api/apps/disconnect", post(routes::apps::disconnect))
.route("/api/approvals", get(routes::approvals::list)) .route("/api/approvals", get(routes::approvals::list))
.route("/api/approvals/{id}", get(routes::approvals::get)) .route("/api/approvals/{id}", get(routes::approvals::get))
.route( .route(
+136 -7
View File
@@ -1,7 +1,14 @@
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::Json; use axum::Json;
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tc_db::repo::audit::Actor;
use tc_domain::AgentId;
use uuid::Uuid;
use crate::Authed; use crate::routes::claws::workspace_agent;
use crate::{ApiError, AppState, Authed};
#[derive(Serialize)] #[derive(Serialize)]
pub struct DirectoryApp { pub struct DirectoryApp {
@@ -11,10 +18,8 @@ pub struct DirectoryApp {
pub category: &'static str, pub category: &'static str,
} }
/// GET /api/apps — the connectable app directory (§8.2). Connecting (OAuth fn catalog() -> Vec<DirectoryApp> {
/// and custom auth) arrives in P4; the directory itself is product data. vec![
pub async fn directory(Authed(_user): Authed) -> Json<Vec<DirectoryApp>> {
Json(vec![
DirectoryApp { DirectoryApp {
id: "gmail", id: "gmail",
name: "Gmail", name: "Gmail",
@@ -117,5 +122,129 @@ pub async fn directory(Authed(_user): Authed) -> Json<Vec<DirectoryApp>> {
description: "Deliver transactional email.", description: "Deliver transactional email.",
category: "Email", category: "Email",
}, },
]) ]
}
#[derive(Deserialize)]
pub struct DirectoryQuery {
#[serde(rename = "clawId")]
claw_id: Option<AgentId>,
}
/// GET /api/apps[?clawId=] — the connect directory (§8.2), with live
/// connection status when scoped to a claw.
pub async fn directory(
State(state): State<AppState>,
Authed(user): Authed,
Query(query): Query<DirectoryQuery>,
) -> Result<Json<Vec<Value>>, ApiError> {
let connections = match query.claw_id {
Some(claw_id) => {
let agent = workspace_agent(&state, &user, claw_id).await?;
tc_db::repo::connections::list_for_agent(&state.pool, user.workspace_id, agent.id)
.await?
}
None => Vec::new(),
};
let merged = catalog()
.into_iter()
.map(|app| {
let connection = connections.iter().find(|c| c.provider == app.id);
json!({
"id": app.id,
"name": app.name,
"description": app.description,
"category": app.category,
"connected": connection.is_some(),
"connection_id": connection.map(|c| c.id),
})
})
.collect();
Ok(Json(merged))
}
#[derive(Deserialize)]
pub struct ConnectRequest {
#[serde(rename = "clawId")]
claw_id: AgentId,
provider: String,
#[serde(rename = "authType")]
auth_type: String,
/// The API key / token (keys) or "user:password" (basic). Sent once,
/// stored encrypted by the broker, never readable again.
secret: String,
}
/// POST /api/apps/connect — custom app credentials (§10 Add Custom App,
/// keys/basic). OAuth and MCP-OAuth flows follow.
pub async fn connect(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<ConnectRequest>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
if !matches!(body.auth_type.as_str(), "keys" | "basic") {
return Err(ApiError::Conflict);
}
let agent = workspace_agent(&state, &user, body.claw_id).await?;
let socket = state.broker_socket.as_ref().ok_or(ApiError::Internal)?;
let mut broker = tc_secrets::BrokerClient::connect(socket)
.await
.map_err(|_| ApiError::Internal)?;
let secret_ref = broker
.store_secret(
user.workspace_id,
&format!("{}_{}", body.provider, body.auth_type),
&body.secret,
)
.await
.map_err(|_| ApiError::Internal)?;
let connection = tc_db::repo::connections::insert(
&state.pool,
user.workspace_id,
Some(agent.id),
&body.provider,
&body.auth_type,
secret_ref,
)
.await?;
tc_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"app.connected",
"app_connection",
&connection.id.to_string(),
json!({"provider": body.provider, "auth_type": body.auth_type}),
)
.await?;
Ok((
StatusCode::CREATED,
Json(json!({"id": connection.id, "provider": connection.provider, "status": "connected"})),
))
}
#[derive(Deserialize)]
pub struct DisconnectRequest {
#[serde(rename = "connectionId")]
connection_id: Uuid,
}
/// POST /api/apps/disconnect
pub async fn disconnect(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<DisconnectRequest>,
) -> Result<StatusCode, ApiError> {
tc_db::repo::connections::disconnect(&state.pool, body.connection_id).await?;
tc_db::repo::audit::append(
&state.pool,
user.workspace_id,
Actor::User(user.user_id),
"app.disconnected",
"app_connection",
&body.connection_id.to_string(),
json!({}),
)
.await?;
Ok(StatusCode::NO_CONTENT)
} }
+1 -4
View File
@@ -37,10 +37,7 @@ async fn serve(pool: sqlx::PgPool) -> TestServer {
let runtime = Runtime::new( let runtime = Runtime::new(
pool.clone(), pool.clone(),
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()), Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig { RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
); );
let app = tc_api::router(AppState::new(pool, runtime)); let app = tc_api::router(AppState::new(pool, runtime));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+1 -4
View File
@@ -12,10 +12,7 @@ fn test_runtime(pool: sqlx::PgPool) -> tc_runtime::Runtime {
tc_runtime::Runtime::new( tc_runtime::Runtime::new(
pool, pool,
std::sync::Arc::new(tc_llm::ScriptedProvider::from_toml("").unwrap()), std::sync::Arc::new(tc_llm::ScriptedProvider::from_toml("").unwrap()),
tc_runtime::RuntimeConfig { tc_runtime::RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
) )
} }
+1 -4
View File
@@ -35,10 +35,7 @@ async fn serve(pool: sqlx::PgPool) -> TestServer {
let runtime = Runtime::new( let runtime = Runtime::new(
pool.clone(), pool.clone(),
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()), Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig { RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
); );
let app = tc_api::router(AppState::new(pool, runtime)); let app = tc_api::router(AppState::new(pool, runtime));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+1 -4
View File
@@ -15,10 +15,7 @@ fn test_runtime(pool: sqlx::PgPool) -> tc_runtime::Runtime {
tc_runtime::Runtime::new( tc_runtime::Runtime::new(
pool, pool,
std::sync::Arc::new(tc_llm::ScriptedProvider::from_toml("").unwrap()), std::sync::Arc::new(tc_llm::ScriptedProvider::from_toml("").unwrap()),
tc_runtime::RuntimeConfig { tc_runtime::RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
) )
} }
+1 -4
View File
@@ -19,10 +19,7 @@ async fn serve(pool: sqlx::PgPool) -> TestServer {
let runtime = Runtime::new( let runtime = Runtime::new(
pool.clone(), pool.clone(),
Arc::new(ScriptedProvider::from_toml("").unwrap()), Arc::new(ScriptedProvider::from_toml("").unwrap()),
RuntimeConfig { RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
); );
let app = tc_api::router(AppState::new(pool, runtime)); let app = tc_api::router(AppState::new(pool, runtime));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+32
View File
@@ -81,6 +81,34 @@ impl Default for StorageConfig {
} }
} }
#[derive(Debug, Clone, Deserialize)]
pub struct BrokerConfig {
/// Unix socket the secret broker daemon listens on.
pub socket_path: String,
}
impl Default for BrokerConfig {
fn default() -> Self {
BrokerConfig {
socket_path: "/tmp/teamclaw-broker.sock".into(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct SlackConfig {
/// Slack API base; e2e points it at the local sink.
pub base_url: String,
}
impl Default for SlackConfig {
fn default() -> Self {
SlackConfig {
base_url: "https://slack.com/api".into(),
}
}
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
pub deploy_target: DeployTarget, pub deploy_target: DeployTarget,
@@ -90,6 +118,10 @@ pub struct AppConfig {
pub auth: AuthConfig, pub auth: AuthConfig,
#[serde(default)] #[serde(default)]
pub storage: StorageConfig, pub storage: StorageConfig,
#[serde(default)]
pub broker: BrokerConfig,
#[serde(default)]
pub slack: SlackConfig,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
+108
View File
@@ -0,0 +1,108 @@
use sqlx::PgPool;
use tc_domain::{AgentId, WorkspaceId};
use uuid::Uuid;
use crate::DbError;
/// A connected app (spec §14 AppConnection). The credential lives in the
/// broker's encrypted store; this row only carries the reference.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AppConnection {
pub id: Uuid,
pub workspace_id: Uuid,
pub agent_id: Option<Uuid>,
pub provider: String,
pub auth_type: String,
pub status: String,
pub secret_ref: Option<Uuid>,
}
pub async fn insert(
pool: &PgPool,
workspace_id: WorkspaceId,
agent_id: Option<AgentId>,
provider: &str,
auth_type: &str,
secret_ref: Uuid,
) -> Result<AppConnection, DbError> {
let id = Uuid::now_v7();
sqlx::query!(
"INSERT INTO app_connections
(id, workspace_id, agent_id, provider, auth_type, status, secret_ref)
VALUES ($1, $2, $3, $4, $5, 'connected', $6)",
id,
workspace_id.as_uuid(),
agent_id.map(|a| a.as_uuid()),
provider,
auth_type,
secret_ref,
)
.execute(pool)
.await?;
Ok(AppConnection {
id,
workspace_id: workspace_id.as_uuid(),
agent_id: agent_id.map(|a| a.as_uuid()),
provider: provider.to_owned(),
auth_type: auth_type.to_owned(),
status: "connected".into(),
secret_ref: Some(secret_ref),
})
}
/// Connections visible to an agent: its own plus workspace-wide ones.
pub async fn list_for_agent(
pool: &PgPool,
workspace_id: WorkspaceId,
agent_id: AgentId,
) -> Result<Vec<AppConnection>, DbError> {
let rows = sqlx::query_as!(
AppConnection,
r#"SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref
FROM app_connections
WHERE workspace_id = $1 AND (agent_id IS NULL OR agent_id = $2)
AND status = 'connected'
ORDER BY created_at"#,
workspace_id.as_uuid(),
agent_id.as_uuid(),
)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// The agent's live connection for one provider, if any.
pub async fn find_provider(
pool: &PgPool,
workspace_id: WorkspaceId,
agent_id: AgentId,
provider: &str,
) -> Result<Option<AppConnection>, DbError> {
let row = sqlx::query_as!(
AppConnection,
r#"SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref
FROM app_connections
WHERE workspace_id = $1 AND (agent_id IS NULL OR agent_id = $2)
AND provider = $3 AND status = 'connected'
ORDER BY created_at DESC LIMIT 1"#,
workspace_id.as_uuid(),
agent_id.as_uuid(),
provider,
)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn disconnect(pool: &PgPool, id: Uuid) -> Result<(), DbError> {
let result = sqlx::query!(
"UPDATE app_connections SET status = 'disconnected' WHERE id = $1",
id,
)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::NotFound);
}
Ok(())
}
+1
View File
@@ -1,5 +1,6 @@
pub mod agents; pub mod agents;
pub mod audit; pub mod audit;
pub mod connections;
pub mod credits; pub mod credits;
pub mod files; pub mod files;
pub mod messages; pub mod messages;
+3
View File
@@ -19,6 +19,7 @@ tc-domain = { path = "../tc-domain" }
tc-files = { path = "../tc-files" } tc-files = { path = "../tc-files" }
tc-llm = { path = "../tc-llm" } tc-llm = { path = "../tc-llm" }
tc-safety = { path = "../tc-safety" } tc-safety = { path = "../tc-safety" }
tc-secrets = { path = "../tc-secrets" }
tc-tools = { path = "../tc-tools" } tc-tools = { path = "../tc-tools" }
thiserror = { workspace = true } thiserror = { workspace = true }
time = { workspace = true } time = { workspace = true }
@@ -26,6 +27,8 @@ tokio = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
[dev-dependencies] [dev-dependencies]
axum = "0.8"
tc-secrets = { path = "../tc-secrets" }
tc-testkit = { path = "../tc-testkit" } tc-testkit = { path = "../tc-testkit" }
[lints] [lints]
+34
View File
@@ -30,6 +30,22 @@ const APPROVAL_TTL: time::Duration = time::Duration::hours(24);
pub struct RuntimeConfig { pub struct RuntimeConfig {
pub model: String, pub model: String,
pub max_tokens: u32, pub max_tokens: u32,
/// Secret broker socket; broker-executed tools require it.
pub broker_socket: Option<std::path::PathBuf>,
/// Slack API base (e2e points it at a local sink).
pub slack_base_url: String,
}
impl RuntimeConfig {
/// Test/dev defaults: no broker, real Slack base.
pub fn basic(model: &str, max_tokens: u32) -> RuntimeConfig {
RuntimeConfig {
model: model.into(),
max_tokens,
broker_socket: None,
slack_base_url: "https://slack.com/api".into(),
}
}
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -343,6 +359,9 @@ impl Runtime {
workspace_id: state.workspace_id, workspace_id: state.workspace_id,
agent_id: state.agent_id, agent_id: state.agent_id,
blob: self.inner.blob.clone(), blob: self.inner.blob.clone(),
approval_id: Some(ready.approval_id),
broker_socket: self.inner.config.broker_socket.clone(),
slack_base_url: self.inner.config.slack_base_url.clone(),
}; };
state.step_seq += 1; state.step_seq += 1;
@@ -353,6 +372,18 @@ impl Runtime {
StepStatus::Error, StepStatus::Error,
json!({"rejected": "The human reviewer rejected this action."}), json!({"rejected": "The human reviewer rejected this action."}),
) )
} else if self.inner.tools.broker_executed(&tool.name) {
// The broker verifies and consumes the grant itself — the
// strongest path: the runtime never touches the credential.
match self
.inner
.tools
.execute(&ctx, &tool.name, tool.input.clone())
.await
{
Ok(value) => (StepStatus::Ok, value),
Err(message) => (StepStatus::Error, json!({"error": message})),
}
} else { } else {
match grants::consume(&self.inner.pool, ready.approval_id).await { match grants::consume(&self.inner.pool, ready.approval_id).await {
// The grant is consumed BEFORE the action runs: even a // The grant is consumed BEFORE the action runs: even a
@@ -398,6 +429,9 @@ impl Runtime {
workspace_id: state.workspace_id, workspace_id: state.workspace_id,
agent_id: state.agent_id, agent_id: state.agent_id,
blob: self.inner.blob.clone(), blob: self.inner.blob.clone(),
approval_id: None,
broker_socket: self.inner.config.broker_socket.clone(),
slack_base_url: self.inner.config.slack_base_url.clone(),
}; };
loop { loop {
+18
View File
@@ -8,6 +8,7 @@ mod clock;
mod email; mod email;
mod files; mod files;
mod routine; mod routine;
mod slack;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -24,6 +25,7 @@ pub use clock::ClockNow;
pub use email::EmailSend; pub use email::EmailSend;
pub use files::{FilesDelete, FilesList, FilesWrite}; pub use files::{FilesDelete, FilesList, FilesWrite};
pub use routine::RoutineSchedule; pub use routine::RoutineSchedule;
pub use slack::SlackPost;
/// Execution context handed to tools: who is acting, for which tenant. /// Execution context handed to tools: who is acting, for which tenant.
#[derive(Clone)] #[derive(Clone)]
@@ -32,6 +34,11 @@ pub struct ToolContext {
pub workspace_id: WorkspaceId, pub workspace_id: WorkspaceId,
pub agent_id: AgentId, pub agent_id: AgentId,
pub blob: Arc<dyn BlobStore>, pub blob: Arc<dyn BlobStore>,
/// Set only while executing an APPROVED gated call; broker-executed
/// tools hand it to the broker, which consumes the grant itself.
pub approval_id: Option<uuid::Uuid>,
pub broker_socket: Option<std::path::PathBuf>,
pub slack_base_url: String,
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -48,6 +55,11 @@ pub trait Tool: Send + Sync {
fn preview(&self, input: &Value) -> Value { fn preview(&self, input: &Value) -> Value {
input.clone() input.clone()
} }
/// Whether the broker executes this tool (and consumes the execution
/// grant itself). Default: the runtime consumes the grant.
fn broker_executed(&self) -> bool {
false
}
async fn execute(&self, ctx: &ToolContext, input: Value) -> Result<Value, String>; async fn execute(&self, ctx: &ToolContext, input: Value) -> Result<Value, String>;
} }
@@ -68,6 +80,7 @@ impl Default for ToolRegistry {
registry.register(Arc::new(RoutineSchedule)); registry.register(Arc::new(RoutineSchedule));
registry.register(Arc::new(ChatSend)); registry.register(Arc::new(ChatSend));
registry.register(Arc::new(ChatInbox)); registry.register(Arc::new(ChatInbox));
registry.register(Arc::new(SlackPost));
registry registry
} }
} }
@@ -94,6 +107,11 @@ impl ToolRegistry {
self.tools.get(name).and_then(|t| t.output_taint()) self.tools.get(name).and_then(|t| t.output_taint())
} }
/// Whether the named tool is broker-executed.
pub fn broker_executed(&self, name: &str) -> bool {
self.tools.get(name).is_some_and(|t| t.broker_executed())
}
/// The approval-card preview for a tool input. /// The approval-card preview for a tool input.
pub fn preview_of(&self, name: &str, input: &Value) -> Value { pub fn preview_of(&self, name: &str, input: &Value) -> Value {
self.tools self.tools
+95
View File
@@ -0,0 +1,95 @@
//! Slack outbound posting (§7.3): a §15 gated category, and the first
//! tool whose approved execution happens INSIDE the secret broker — the
//! runtime never touches the bot token, and the broker independently
//! consumes the single-use grant before calling Slack.
use serde_json::{json, Value};
use tc_llm::ToolDescriptor;
use tc_tools::Effect;
use super::{Tool, ToolContext};
pub struct SlackPost;
#[async_trait::async_trait]
impl Tool for SlackPost {
fn descriptor(&self) -> ToolDescriptor {
ToolDescriptor {
name: "slack.post".into(),
description: "Posts a message to a Slack channel. Requires \
human approval before it executes."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"channel": {"type": "string"},
"text": {"type": "string"},
},
"required": ["channel", "text"],
}),
}
}
fn effects(&self) -> &'static [Effect] {
&[Effect::SendsExternally]
}
/// The broker consumes the execution grant itself; the runtime must
/// not pre-consume it.
fn broker_executed(&self) -> bool {
true
}
fn preview(&self, input: &Value) -> Value {
json!({
"summary": format!(
"Post to Slack {}",
input["channel"].as_str().unwrap_or("(missing channel)")
),
"channel": input["channel"],
"text": input["text"],
})
}
async fn execute(&self, ctx: &ToolContext, input: Value) -> Result<Value, String> {
let channel = input["channel"].as_str().ok_or("missing 'channel'")?;
let text = input["text"].as_str().ok_or("missing 'text'")?;
let approval_id = ctx
.approval_id
.ok_or("slack.post can only run as an approved gated action")?;
let socket = ctx
.broker_socket
.as_ref()
.ok_or("secret broker is not configured")?;
let connection = tc_db::repo::connections::find_provider(
&ctx.pool,
ctx.workspace_id,
ctx.agent_id,
"slack",
)
.await
.map_err(|e| e.to_string())?
.ok_or("Slack is not connected for this claw")?;
let secret_ref = connection
.secret_ref
.ok_or("connection has no credential")?;
let mut broker = tc_secrets::BrokerClient::connect(socket)
.await
.map_err(|e| format!("broker unreachable: {e}"))?;
let status = broker
.invoke_http(
approval_id,
secret_ref,
&format!("{}/chat.postMessage", ctx.slack_base_url),
json!({"channel": channel, "text": text}),
)
.await
.map_err(|e| format!("slack post failed: {e}"))?;
if !(200..300).contains(&(status as u16 as i32 as u16)) && status != 200 {
return Err(format!("slack returned status {status}"));
}
Ok(json!({ "posted": true, "channel": channel, "status": status }))
}
}
+1 -4
View File
@@ -94,10 +94,7 @@ fn runtime(pool: sqlx::PgPool) -> Runtime {
Runtime::new( Runtime::new(
pool, pool,
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()), Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig { RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
) )
} }
+1 -4
View File
@@ -87,10 +87,7 @@ fn runtime(pool: sqlx::PgPool) -> (Runtime, std::path::PathBuf) {
let rt = Runtime::with_blob_store( let rt = Runtime::with_blob_store(
pool, pool,
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()), Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig { RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
Arc::new(LocalBlobStore::new(root.clone())), Arc::new(LocalBlobStore::new(root.clone())),
); );
(rt, root) (rt, root)
+2 -8
View File
@@ -78,10 +78,7 @@ fn runtime(pool: sqlx::PgPool) -> Runtime {
Runtime::new( Runtime::new(
pool, pool,
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()), Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig { RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
) )
} }
@@ -317,10 +314,7 @@ events = [ { type = "text", text = "Done." } ]
let rt = Runtime::new( let rt = Runtime::new(
pool.clone(), pool.clone(),
Arc::new(ScriptedProvider::from_toml(scenarios).unwrap()), Arc::new(ScriptedProvider::from_toml(scenarios).unwrap()),
RuntimeConfig { RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
); );
let session = tc_db::repo::sessions::create(&pool, seed.agent.id, seed.workspace.id, "Chat") let session = tc_db::repo::sessions::create(&pool, seed.agent.id, seed.workspace.id, "Chat")
.await .await
+2 -8
View File
@@ -61,10 +61,7 @@ fn runtime(pool: sqlx::PgPool) -> Runtime {
Runtime::new( Runtime::new(
pool, pool,
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()), Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig { RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
) )
} }
@@ -215,10 +212,7 @@ events = [ { type = "text", text = "I could not use that tool." } ]
let rt = Runtime::new( let rt = Runtime::new(
pool.clone(), pool.clone(),
Arc::new(ScriptedProvider::from_toml(scenarios).unwrap()), Arc::new(ScriptedProvider::from_toml(scenarios).unwrap()),
RuntimeConfig { RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
); );
let started = rt let started = rt
+204
View File
@@ -0,0 +1,204 @@
//! The P4 blocking property: Slack outbound is gated, and the approved
//! execution happens inside the broker (grant consumed there, bot token
//! never visible to the runtime). Real broker socket, real Postgres, real
//! local Slack-shaped receiver.
use std::sync::Arc;
use std::time::Duration;
use axum::extract::State;
use axum::routing::post;
use serde_json::{json, Value};
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, RunState, User, UserId, Workspace, WorkspaceId,
};
use tc_llm::ScriptedProvider;
use tc_runtime::{RunEventBody, Runtime, RuntimeConfig};
use tc_safety::{approvals, Decision};
use tc_secrets::{BrokerClient, BrokerServer, FileKey};
use tokio::sync::Mutex;
const SCENARIOS: &str = r##"
[[scenario]]
marker = "[[scenario:slack-post]]"
[[scenario.turns]]
events = [
{ type = "tool_use", name = "slack.post", input = { channel = "#general", text = "Q2 numbers are in." } },
]
[[scenario.turns]]
events = [
{ type = "text", text = " Posted to Slack." },
]
"##;
type Posts = Arc<Mutex<Vec<(String, Value)>>>;
/// A Slack-shaped receiver recording (authorization, body) pairs.
async fn spawn_slack_sink() -> (String, Posts) {
let seen: Posts = Arc::new(Mutex::new(Vec::new()));
let state = seen.clone();
let app = axum::Router::new()
.route(
"/chat.postMessage",
post(
|State(seen): State<Posts>,
headers: axum::http::HeaderMap,
axum::Json(body): axum::Json<Value>| async move {
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
seen.lock().await.push((auth, body));
axum::Json(json!({"ok": 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 spawn_broker(pool: sqlx::PgPool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("tc-slk-{}", 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/tcs-{}.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 slack_post_blocks_then_the_broker_executes_exactly_once() {
let pool = tc_testkit::test_pool().await;
let (sink_url, posts) = spawn_slack_sink().await;
let socket = spawn_broker(pool.clone()).await;
// Seed workspace, owner, agent.
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
tc_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,
};
tc_db::repo::users::insert(&pool, &owner).await.unwrap();
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.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,
};
tc_db::repo::agents::insert(&pool, &agent, &AccessPolicy::default())
.await
.unwrap();
// Connect Slack: bot token stored through the broker, row references it.
let mut client = BrokerClient::connect(&socket).await.unwrap();
let secret_ref = client
.store_secret(ws.id, "slack_bot_token", "xoxb-test-token")
.await
.unwrap();
tc_db::repo::connections::insert(&pool, ws.id, Some(agent.id), "slack", "keys", secret_ref)
.await
.unwrap();
let rt = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig {
model: "scripted".into(),
max_tokens: 1024,
broker_socket: Some(socket),
slack_base_url: sink_url,
},
);
let session = tc_db::repo::sessions::create(&pool, agent.id, ws.id, "Slack")
.await
.unwrap();
let started = rt
.send_message(session.id, "post it [[scenario:slack-post]]")
.await
.unwrap();
// Suspends with the outbound_message category; nothing posted.
let mut rx = started.events;
let mut suspended = false;
while let Ok(envelope) = rx.recv().await {
if matches!(envelope.event, RunEventBody::RunSuspended { .. }) {
suspended = true;
break;
}
if matches!(envelope.event, RunEventBody::Error { .. }) {
break;
}
}
assert!(suspended);
assert!(posts.lock().await.is_empty(), "blocked while pending");
// Approve → the broker posts with the bot token injected.
let pending = approvals::list_pending(&pool, ws.id).await.unwrap();
assert_eq!(
pending[0].category,
tc_domain::GatedCategory::OutboundMessage
);
approvals::decide(&pool, pending[0].id, owner.id, Decision::Approve)
.await
.unwrap();
rt.resume_run(tc_safety::ResumeReady {
run_id: started.run_id,
approval_id: pending[0].id,
approved: true,
})
.await
.unwrap();
for _ in 0..100 {
let run = tc_db::repo::runs::get(&pool, started.run_id).await.unwrap();
if run.state == RunState::Completed {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
let observed = posts.lock().await;
assert_eq!(observed.len(), 1, "exactly one post");
assert_eq!(observed[0].0, "Bearer xoxb-test-token");
assert_eq!(observed[0].1["channel"], "#general");
assert_eq!(observed[0].1["text"], "Q2 numbers are in.");
drop(observed);
// The grant was consumed by the broker: replay refuses.
let replay = tc_safety::grants::consume(&pool, pending[0].id).await;
assert!(replay.is_err(), "grant must already be consumed");
}
+2 -8
View File
@@ -87,10 +87,7 @@ async fn due_routines_fire_real_runs_exactly_once() {
let runtime = Runtime::new( let runtime = Runtime::new(
pool.clone(), pool.clone(),
Arc::new(ScriptedProvider::from_toml("").unwrap()), Arc::new(ScriptedProvider::from_toml("").unwrap()),
RuntimeConfig { RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
); );
let scheduler = Scheduler::new(pool.clone(), runtime); let scheduler = Scheduler::new(pool.clone(), runtime);
@@ -176,10 +173,7 @@ async fn paused_routines_do_not_fire() {
let runtime = Runtime::new( let runtime = Runtime::new(
pool.clone(), pool.clone(),
Arc::new(ScriptedProvider::from_toml("").unwrap()), Arc::new(ScriptedProvider::from_toml("").unwrap()),
RuntimeConfig { RuntimeConfig::basic("scripted", 1024),
model: "scripted".into(),
max_tokens: 1024,
},
); );
let scheduler = Scheduler::new(pool.clone(), runtime); let scheduler = Scheduler::new(pool.clone(), runtime);
+2
View File
@@ -57,12 +57,14 @@ impl BrokerClient {
approval_id: Uuid, approval_id: Uuid,
secret_id: Uuid, secret_id: Uuid,
url: &str, url: &str,
body: serde_json::Value,
) -> Result<u16, BrokerError> { ) -> Result<u16, BrokerError> {
match self match self
.round_trip(Request::InvokeHttp { .round_trip(Request::InvokeHttp {
approval_id, approval_id,
secret_id, secret_id,
url: url.to_owned(), url: url.to_owned(),
body,
}) })
.await? .await?
{ {
+2
View File
@@ -26,6 +26,8 @@ pub enum Request {
approval_id: Uuid, approval_id: Uuid,
secret_id: Uuid, secret_id: Uuid,
url: String, url: String,
#[serde(default)]
body: serde_json::Value,
}, },
} }
+2
View File
@@ -80,6 +80,7 @@ impl BrokerServer {
approval_id, approval_id,
secret_id, secret_id,
url, url,
body,
} => { } => {
if !url.starts_with("https://") && !url.starts_with("http://") { if !url.starts_with("https://") && !url.starts_with("http://") {
return Err(BrokerError::Invalid(format!( return Err(BrokerError::Invalid(format!(
@@ -96,6 +97,7 @@ impl BrokerServer {
let response = reqwest::Client::new() let response = reqwest::Client::new()
.post(&url) .post(&url)
.bearer_auth(credential) .bearer_auth(credential)
.json(&body)
.send() .send()
.await .await
.map_err(|e| BrokerError::Io(e.to_string()))?; .map_err(|e| BrokerError::Io(e.to_string()))?;
+19 -4
View File
@@ -205,7 +205,12 @@ async fn capability_requires_an_unconsumed_grant() {
.await .await
.unwrap(); .unwrap();
let refused = client let refused = client
.invoke_http(pending.id, secret_id, &format!("{receiver_url}/hook")) .invoke_http(
pending.id,
secret_id,
&format!("{receiver_url}/hook"),
json!({}),
)
.await; .await;
assert!(matches!(refused, Err(BrokerError::GrantRefused))); assert!(matches!(refused, Err(BrokerError::GrantRefused)));
assert!(seen.lock().await.is_empty(), "nothing may execute"); assert!(seen.lock().await.is_empty(), "nothing may execute");
@@ -214,7 +219,12 @@ async fn capability_requires_an_unconsumed_grant() {
// credential itself never crosses back over the socket. // credential itself never crosses back over the socket.
let approval_id = approved_approval(&pool, &seed).await; let approval_id = approved_approval(&pool, &seed).await;
let status = client let status = client
.invoke_http(approval_id, secret_id, &format!("{receiver_url}/hook")) .invoke_http(
approval_id,
secret_id,
&format!("{receiver_url}/hook"),
json!({}),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(status, 200); assert_eq!(status, 200);
@@ -224,7 +234,12 @@ async fn capability_requires_an_unconsumed_grant() {
// The grant is single-use: replay refused, no second call. // The grant is single-use: replay refused, no second call.
let replay = client let replay = client
.invoke_http(approval_id, secret_id, &format!("{receiver_url}/hook")) .invoke_http(
approval_id,
secret_id,
&format!("{receiver_url}/hook"),
json!({}),
)
.await; .await;
assert!(matches!(replay, Err(BrokerError::GrantRefused))); assert!(matches!(replay, Err(BrokerError::GrantRefused)));
assert_eq!(seen.lock().await.len(), 1); assert_eq!(seen.lock().await.len(), 1);
@@ -244,7 +259,7 @@ async fn non_http_urls_are_rejected() {
.unwrap(); .unwrap();
let approval_id = approved_approval(&pool, &seed).await; let approval_id = approved_approval(&pool, &seed).await;
let refused = client let refused = client
.invoke_http(approval_id, secret_id, "file:///etc/passwd") .invoke_http(approval_id, secret_id, "file:///etc/passwd", json!({}))
.await; .await;
assert!(matches!(refused, Err(BrokerError::Invalid(_)))); assert!(matches!(refused, Err(BrokerError::Invalid(_))));
} }
+14
View File
@@ -63,3 +63,17 @@ events = [
events = [ events = [
{ type = "text", text = "Scheduled the morning digest for 9am daily." }, { type = "text", text = "Scheduled the morning digest for 9am daily." },
] ]
[[scenario]]
marker = "[[scenario:slack-post]]"
[[scenario.turns]]
events = [
{ type = "text", text = "I'll post that once you approve." },
{ type = "tool_use", name = "slack.post", input = { channel = "#general", text = "Q2 revenue is up 14%." } },
]
[[scenario.turns]]
events = [
{ type = "text", text = " Posted to #general." },
]
+6
View File
@@ -14,3 +14,9 @@ scenario_path = "deploy/e2e/scenarios.toml"
[auth] [auth]
mode = "local" mode = "local"
[broker]
socket_path = "/tmp/teamclaw-e2e-broker.sock"
[slack]
base_url = "http://127.0.0.1:8080/__slack"
@@ -1,15 +1,47 @@
"use client"; "use client";
import { useState } from "react"; import { useState, type FormEvent } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { useFetchJson } from "@/lib/api/use-fetch";
const TABS = ["Overview", "Channels", "Connection"] as const; const TABS = ["Overview", "Channels", "Connection"] as const;
/** The Slack app (§7.3). No connection exists until P4 wires OAuth, so interface DirectoryEntry {
* every tab shows the spec's pre-connect gate. */ id: string;
connected: boolean;
}
/** The Slack app (§7.3): pre-connect gate on every tab; the Connection tab
* stores the bot token through the secret broker. Outbound posts are
* always gated behind human approval. */
export default function SlackApp({ agent }: { agent: Agent }) { export default function SlackApp({ agent }: { agent: Agent }) {
const [tab, setTab] = useState<(typeof TABS)[number]>("Overview"); const [tab, setTab] = useState<(typeof TABS)[number]>("Overview");
const [saving, setSaving] = useState(false);
const directory = useFetchJson<DirectoryEntry[]>(
`/api/apps?clawId=${agent.id}`,
);
const connected =
directory.data?.find((entry) => entry.id === "slack")?.connected ?? false;
async function connect(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSaving(true);
const data = new FormData(event.currentTarget);
await fetch("/api/apps/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clawId: agent.id,
provider: "slack",
authType: "keys",
secret: data.get("token"),
}),
});
setSaving(false);
directory.refresh();
setTab("Overview");
}
return ( return (
<div className="flex h-full flex-col p-3"> <div className="flex h-full flex-col p-3">
@@ -31,19 +63,61 @@ export default function SlackApp({ agent }: { agent: Agent }) {
</button> </button>
))} ))}
</div> </div>
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
<span aria-hidden className="text-2xl"> {tab === "Connection" && !connected ? (
💬 <form onSubmit={connect} className="flex flex-col gap-3">
</span> <label className="flex flex-col gap-1 text-xs text-muted-foreground">
<p className="text-sm font-medium">Bring this claw into Slack</p> Bot token
<p className="max-w-60 text-xs text-muted-foreground"> <input
Connect Slack so {agent.name} can respond when someone @mentions it. name="token"
Outbound posts always require approval. type="password"
</p> required
<p className="rounded-(--radius) border border-border px-3 py-1.5 text-xs text-muted-foreground"> placeholder="xoxb-…"
Slack connection arrives with integrations (P4). className="rounded-(--radius) border border-input bg-subtle px-2 py-1.5 text-sm text-foreground outline-none focus:border-accent"
</p> />
</div> <span className="text-xxs">
Stored encrypted by the secret broker — never readable again,
even by admins.
</span>
</label>
<button
type="submit"
disabled={saving}
className="self-start rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background hover:bg-coral-light disabled:opacity-50"
>
{saving ? "Connecting…" : "Connect Slack"}
</button>
</form>
) : connected ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
<span aria-hidden className="text-2xl">
✅
</span>
<p className="text-sm font-medium">Slack is connected</p>
<p className="max-w-60 text-xs text-muted-foreground">
{agent.name} can be asked to post — every outbound message still
requires your approval first.
</p>
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
<span aria-hidden className="text-2xl">
💬
</span>
<p className="text-sm font-medium">Bring this claw into Slack</p>
<p className="max-w-60 text-xs text-muted-foreground">
Connect Slack so {agent.name} can post on your behalf. Outbound
posts always require approval.
</p>
<button
type="button"
onClick={() => setTab("Connection")}
className="rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background hover:bg-coral-light"
>
Connect Slack
</button>
</div>
)}
</div> </div>
); );
} }
+61
View File
@@ -0,0 +1,61 @@
import { expect, test, type Page } from "@playwright/test";
// P4 exit criterion (spec §17): connect an app, and Slack outbound is
// provably blocked without approval — then executed BY the secret broker
// exactly once after approval (asserted against the e2e Slack sink).
const OWNER_EMAIL = "[email protected]";
const OWNER_PASSWORD = "e2e-password";
async function signIn(page: Page) {
await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "TeamClaw" })).toBeVisible();
}
test("connect Slack, then an outbound post is gated and broker-executed", async ({
page,
request,
}) => {
await signIn(page);
await page.getByRole("link", { name: /Scout/ }).click();
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
// Connect Slack through the panel: the token goes to the broker.
await page.getByRole("button", { name: "Computer" }).click();
const panel = page.getByRole("complementary", { name: "Computer" });
await panel.getByRole("button", { name: "Slack" }).click();
await panel.getByRole("button", { name: "Connect Slack" }).first().click();
await panel.getByLabel(/Bot token/).fill("xoxb-e2e-token");
await panel.getByRole("button", { name: "Connect Slack" }).click();
await expect(panel.getByText("Slack is connected")).toBeVisible();
await panel.getByRole("button", { name: "Close computer" }).click();
// Ask for a post: blocked behind the approval card.
const box = page.getByLabel("Message Scout");
await box.fill("share the numbers [[scenario:slack-post]]");
await box.press("Enter");
const card = page.getByRole("region", { name: "Review and approve" });
await expect(card).toBeVisible();
await expect(card.getByText(/wants to:/)).toContainText("Post to Slack #general");
// Provably blocked: the sink saw nothing.
const before = await request.get("http://127.0.0.1:8080/__slack/posts");
expect(await before.json()).toHaveLength(0);
// Approve → the broker posts exactly once.
await card.getByRole("button", { name: "Approve" }).click();
await expect(page.getByText(/Posted to #general/)).toBeVisible();
const after = await request.get("http://127.0.0.1:8080/__slack/posts");
const posts = (await after.json()) as { channel: string; text: string }[];
expect(posts).toHaveLength(1);
expect(posts[0].channel).toBe("#general");
expect(posts[0].text).toBe("Q2 revenue is up 14%.");
// The audit trail shows the decision.
await page.getByRole("link", { name: "Approvals" }).click();
await expect(page.getByText(/All clear/)).toBeVisible();
});
+11
View File
@@ -21,4 +21,15 @@ cd "$ROOT"
export TEAMCLAW_MODE=e2e export TEAMCLAW_MODE=e2e
export TEAMCLAW_CONFIG="$ROOT/deploy/e2e/teamclaw.e2e.toml" export TEAMCLAW_CONFIG="$ROOT/deploy/e2e/teamclaw.e2e.toml"
export SQLX_OFFLINE=true export SQLX_OFFLINE=true
# The secret broker daemon (own process, like production).
export TEAMCLAW_DATABASE__URL="postgres://postgres:[email protected]:54330/teamclaw_e2e"
export TEAMCLAW_BROKER_SOCKET="/tmp/teamclaw-e2e-broker.sock"
export TEAMCLAW_BROKER_KEY_FILE="/tmp/teamclaw-e2e-broker.key"
rm -f "$TEAMCLAW_BROKER_SOCKET" "$TEAMCLAW_BROKER_KEY_FILE"
cargo build -p teamclaw-server -p teamclaw-broker
cargo run -p teamclaw-broker &
BROKER_PID=$!
trap 'kill $BROKER_PID 2>/dev/null' EXIT
exec cargo run -p teamclaw-server exec cargo run -p teamclaw-server