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:
co-authored by
Claude Fable 5
parent
1fd2c287f1
commit
000b9b3a4b
+19
@@ -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"
|
||||||
|
}
|
||||||
+14
@@ -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"
|
||||||
|
}
|
||||||
+59
@@ -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"
|
||||||
|
}
|
||||||
+60
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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))?;
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()))?;
|
||||||
|
|||||||
@@ -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(_))));
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
@@ -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." },
|
||||||
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user