P4: Slack inbound @mention — broker-verified signatures drive real runs

- Broker op VerifySlackSignature: v0 HMAC-SHA256 computed INSIDE the broker
  (constant-time compare); the signing secret never crosses the socket.
  Slack secrets are one JSON credential {bot_token, signing_secret}; the
  broker extracts the right field per operation
- Public POST /api/slack/events: signature verified against connected slack
  connections via the broker; forged signatures 401; url_verification
  handshake echoed only when signed; app_mention starts a real run in the
  agent's dedicated '💬 Slack' session — and the agent's reply is itself a
  gated outbound post
- SlackApp Connection tab captures bot token + signing secret
- Integration test: forged 401, signed challenge, signed mention -> run ->
  slack.post pending in the approval queue
- E2E: full loop — connect, gated outbound (sink empty -> exactly one post),
  then a node-crypto-signed mention -> approval card -> approve -> 'On it!'
  lands in the sink

134 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 06:40:04 -05:00
co-authored by Claude Fable 5
parent 000b9b3a4b
commit 6dbdd20ee0
16 changed files with 548 additions and 2 deletions
@@ -0,0 +1,56 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref\n FROM app_connections WHERE provider = 'slack' AND status = 'connected'",
"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": []
},
"nullable": [
false,
false,
true,
false,
false,
false,
true
]
},
"hash": "fbc9d96386a0a1b3f1138f4116cf59042c38fe1d9bf8d8db014fc23fd117800b"
}
Generated
+5
View File
@@ -2850,9 +2850,12 @@ dependencies = [
"axum", "axum",
"eventsource-stream", "eventsource-stream",
"futures", "futures",
"hex",
"hmac",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sqlx", "sqlx",
"tc-auth", "tc-auth",
"tc-db", "tc-db",
@@ -3028,9 +3031,11 @@ dependencies = [
"axum", "axum",
"chacha20poly1305", "chacha20poly1305",
"hex", "hex",
"hmac",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sqlx", "sqlx",
"tc-db", "tc-db",
"tc-domain", "tc-domain",
+3
View File
@@ -34,6 +34,9 @@ reqwest = { version = "0.12", default-features = false, features = [
] } ] }
tc-llm = { path = "../tc-llm" } tc-llm = { path = "../tc-llm" }
tc-testkit = { path = "../tc-testkit" } tc-testkit = { path = "../tc-testkit" }
hex = "0.4"
hmac = "0.12"
sha2 = "0.10"
urlencoding = "2" urlencoding = "2"
[lints] [lints]
+1
View File
@@ -69,6 +69,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/skills/uninstall", post(routes::skills::uninstall)) .route("/api/skills/uninstall", post(routes::skills::uninstall))
.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/slack/events", post(routes::slack::events))
.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/connect", post(routes::apps::connect))
.route("/api/apps/disconnect", post(routes::apps::disconnect)) .route("/api/apps/disconnect", post(routes::apps::disconnect))
+1
View File
@@ -10,4 +10,5 @@ pub mod identity;
pub mod routines; pub mod routines;
pub mod sessions; pub mod sessions;
pub mod skills; pub mod skills;
pub mod slack;
pub mod team; pub mod team;
+125
View File
@@ -0,0 +1,125 @@
//! Slack inbound events (§7.3: "Agent replies on @mention"). This route is
//! public — authenticity comes from Slack's request signature, which the
//! BROKER verifies against the stored signing secret (it never leaves the
//! broker). A verified @mention starts a real run in the agent's dedicated
//! Slack session; any reply the agent attempts is itself a gated outbound
//! post.
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde_json::{json, Value};
use tc_domain::AgentId;
use crate::AppState;
const SLACK_SESSION_TITLE: &str = "💬 Slack";
/// Finds the slack connection whose signing secret validates this request.
async fn verified_connection(
state: &AppState,
timestamp: &str,
body: &str,
signature: &str,
) -> Option<tc_db::repo::connections::AppConnection> {
let socket = state.broker_socket.as_ref()?;
let connections = sqlx::query!(
r#"SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref
FROM app_connections WHERE provider = 'slack' AND status = 'connected'"#,
)
.fetch_all(&state.pool)
.await
.ok()?;
for row in connections {
let Some(secret_ref) = row.secret_ref else {
continue;
};
let Ok(mut broker) = tc_secrets::BrokerClient::connect(socket).await else {
return None;
};
if broker
.verify_slack_signature(secret_ref, timestamp, body, signature)
.await
.unwrap_or(false)
{
return Some(tc_db::repo::connections::AppConnection {
id: row.id,
workspace_id: row.workspace_id,
agent_id: row.agent_id,
provider: row.provider,
auth_type: row.auth_type,
status: row.status,
secret_ref: row.secret_ref,
});
}
}
None
}
/// POST /api/slack/events
pub async fn events(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, StatusCode> {
let timestamp = headers
.get("x-slack-request-timestamp")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
let signature = headers
.get("x-slack-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
let raw = String::from_utf8_lossy(&body).into_owned();
let connection = verified_connection(&state, timestamp, &raw, signature)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let event: Value = serde_json::from_str(&raw).map_err(|_| StatusCode::BAD_REQUEST)?;
// Slack's endpoint handshake.
if event["type"] == "url_verification" {
return Ok(Json(json!({ "challenge": event["challenge"] })));
}
if event["type"] == "event_callback" && event["event"]["type"] == "app_mention" {
let text = event["event"]["text"]
.as_str()
.unwrap_or_default()
.to_owned();
let Some(agent_uuid) = connection.agent_id else {
return Ok(Json(json!({ "ok": true })));
};
let agent_id = AgentId::from(agent_uuid);
let Ok(agent) = tc_db::repo::agents::get(&state.pool, agent_id).await else {
return Ok(Json(json!({ "ok": true })));
};
// One recognizable session per agent for Slack traffic.
let session = match tc_db::repo::sessions::list_by_agent(&state.pool, agent_id)
.await
.ok()
.and_then(|sessions| {
sessions
.into_iter()
.find(|s| s.title == SLACK_SESSION_TITLE)
}) {
Some(existing) => existing,
None => tc_db::repo::sessions::create(
&state.pool,
agent_id,
agent.workspace_id,
SLACK_SESSION_TITLE,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
};
// Fire-and-forget: Slack expects a fast 200; the run streams into
// the session (and any outbound reply is gated as usual).
let runtime = state.runtime.clone();
tokio::spawn(async move {
let _ = runtime.send_message(session.id, &text).await;
});
}
Ok(Json(json!({ "ok": true })))
}
+205
View File
@@ -0,0 +1,205 @@
//! Inbound Slack @mentions: signature verified BY the broker, a verified
//! mention drives a real run, and the agent's reply is gated as usual.
use std::sync::Arc;
use std::time::Duration;
use hmac::{Hmac, Mac};
use serde_json::{json, Value};
use tc_api::AppState;
use tc_auth::AuthService;
use tc_domain::{Role, User, UserId, Workspace, WorkspaceId};
use tc_llm::ScriptedProvider;
use tc_runtime::{Runtime, RuntimeConfig};
use tc_secrets::{BrokerServer, FileKey};
const SCENARIOS: &str = r##"
[[scenario]]
marker = "[[scenario:mention]]"
[[scenario.turns]]
events = [
{ type = "tool_use", name = "slack.post", input = { channel = "#general", text = "On it!" } },
]
[[scenario.turns]]
events = [
{ type = "text", text = " Replied in Slack." },
]
"##;
fn sign(secret: &str, timestamp: &str, body: &str) -> String {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("v0:{timestamp}:{body}").as_bytes());
format!("v0={}", hex::encode(mac.finalize().into_bytes()))
}
async fn spawn_broker(pool: sqlx::PgPool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("tc-in-{}", uuid::Uuid::now_v7().simple()));
std::fs::create_dir_all(&dir).unwrap();
let key_path = dir.join("broker.key");
FileKey::generate(&key_path).unwrap();
let key = FileKey::load(&key_path).unwrap();
let short = uuid::Uuid::now_v7().simple().to_string();
let socket = std::path::PathBuf::from(format!("/tmp/tci-{}.sock", &short[short.len() - 12..]));
let server = BrokerServer::new(pool, key, socket.clone());
tokio::spawn(async move {
server.serve().await.unwrap();
});
for _ in 0..50 {
if socket.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
socket
}
#[tokio::test]
async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
let pool = tc_testkit::test_pool().await;
let socket = spawn_broker(pool.clone()).await;
let runtime = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig {
model: "scripted".into(),
max_tokens: 1024,
broker_socket: Some(socket.clone()),
slack_base_url: "http://127.0.0.1:1".into(), // never reached here
},
);
let app = tc_api::router(AppState::new(pool.clone(), runtime).with_broker(socket.clone()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base = format!("http://{addr}");
let client = reqwest::Client::new();
// Seed + connect Slack with a JSON secret (bot token + signing secret).
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
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();
AuthService::new(pool.clone())
.set_password(owner.id, "pw")
.await
.unwrap();
let token = client
.post(format!("{base}/api/auth/login"))
.json(&json!({"email": owner.email, "password": "pw"}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_owned();
let claw: Value = client
.post(format!("{base}/api/claws"))
.bearer_auth(&token)
.json(&json!({"name": "Scout", "job_title": "Analyst"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let claw_id = claw["id"].as_str().unwrap();
let signing_secret = "shh-signing";
let connect = client
.post(format!("{base}/api/apps/connect"))
.bearer_auth(&token)
.json(&json!({
"clawId": claw_id,
"provider": "slack",
"authType": "keys",
"secret": json!({"bot_token": "xoxb-in", "signing_secret": signing_secret}).to_string(),
}))
.send()
.await
.unwrap();
assert_eq!(connect.status(), 201);
// A forged signature is rejected outright.
let body = json!({
"type": "event_callback",
"event": {"type": "app_mention", "text": "summarize [[scenario:mention]]"}
})
.to_string();
let forged = client
.post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", "12345")
.header("x-slack-signature", "v0=deadbeef")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(forged.status(), 401);
// url_verification handshake echoes the challenge when signed.
let challenge_body = json!({"type": "url_verification", "challenge": "abc123"}).to_string();
let challenge = client
.post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", "12345")
.header(
"x-slack-signature",
sign(signing_secret, "12345", &challenge_body),
)
.body(challenge_body.clone())
.send()
.await
.unwrap();
assert_eq!(challenge.status(), 200);
assert_eq!(
challenge.json::<Value>().await.unwrap()["challenge"],
"abc123"
);
// A properly signed mention starts a run in the '💬 Slack' session and
// the agent's reply is intercepted by the approval gate.
let mention = client
.post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", "12345")
.header("x-slack-signature", sign(signing_secret, "12345", &body))
.body(body)
.send()
.await
.unwrap();
assert_eq!(mention.status(), 200);
let agent_id = tc_domain::AgentId::from(claw_id.parse::<uuid::Uuid>().unwrap());
let mut gated = false;
for _ in 0..100 {
let sessions = tc_db::repo::sessions::list_by_agent(&pool, agent_id)
.await
.unwrap();
if sessions.iter().any(|s| s.title == "💬 Slack") {
let pending = tc_safety::approvals::list_pending(&pool, ws.id)
.await
.unwrap();
if pending.len() == 1 && pending[0].action_type == "slack.post" {
gated = true;
break;
}
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(gated, "mention must drive a run whose reply is gated");
}
+2
View File
@@ -9,6 +9,8 @@ publish.workspace = true
[dependencies] [dependencies]
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
hex = "0.4" hex = "0.4"
hmac = "0.12"
sha2 = "0.10"
reqwest = { version = "0.12", default-features = false, features = [ reqwest = { version = "0.12", default-features = false, features = [
"json", "json",
"rustls-tls", "rustls-tls",
+23
View File
@@ -52,6 +52,29 @@ impl BrokerClient {
} }
} }
/// Asks the broker to verify a Slack signature; the signing secret
/// never crosses the socket.
pub async fn verify_slack_signature(
&mut self,
secret_id: Uuid,
timestamp: &str,
body: &str,
signature: &str,
) -> Result<bool, BrokerError> {
match self
.round_trip(Request::VerifySlackSignature {
secret_id,
timestamp: timestamp.to_owned(),
body: body.to_owned(),
signature: signature.to_owned(),
})
.await?
{
Response::Verified { valid } => Ok(valid),
other => Err(BrokerError::Io(format!("unexpected response: {other:?}"))),
}
}
pub async fn invoke_http( pub async fn invoke_http(
&mut self, &mut self,
approval_id: Uuid, approval_id: Uuid,
+9
View File
@@ -20,6 +20,14 @@ pub enum Request {
SecretKind { SecretKind {
secret_id: Uuid, secret_id: Uuid,
}, },
/// Verifies a Slack request signature (v0 HMAC-SHA256) against the
/// connection's signing secret — which never leaves the broker.
VerifySlackSignature {
secret_id: Uuid,
timestamp: String,
body: String,
signature: String,
},
/// Performs an HTTP POST with the secret injected as a bearer token. /// Performs an HTTP POST with the secret injected as a bearer token.
/// Requires consuming the approval's single-use execution grant. /// Requires consuming the approval's single-use execution grant.
InvokeHttp { InvokeHttp {
@@ -37,6 +45,7 @@ pub enum Response {
SecretStored { secret_id: Uuid }, SecretStored { secret_id: Uuid },
SecretKind { kind: String }, SecretKind { kind: String },
HttpDone { status: u16 }, HttpDone { status: u16 },
Verified { valid: bool },
Error { kind: ErrorKind, message: String }, Error { kind: ErrorKind, message: String },
} }
+46 -1
View File
@@ -9,6 +9,38 @@ use crate::protocol::{read_frame, write_frame, Request, Response};
use crate::store::SecretStore; use crate::store::SecretStore;
use crate::BrokerError; use crate::BrokerError;
/// Secrets may be plain strings or JSON objects holding multiple fields
/// (e.g. Slack's bot token + signing secret). Returns the requested field
/// for JSON secrets, the whole value otherwise.
fn credential_field(secret: &str, field: &str) -> String {
serde_json::from_str::<serde_json::Value>(secret)
.ok()
.and_then(|v| v.get(field).and_then(|f| f.as_str()).map(str::to_owned))
.unwrap_or_else(|| secret.to_owned())
}
/// Slack request signing: v0=hex(HMAC-SHA256(secret, "v0:{ts}:{body}")).
pub(crate) fn slack_signature_valid(
signing_secret: &str,
timestamp: &str,
body: &str,
signature: &str,
) -> bool {
use hmac::{Hmac, Mac};
let Ok(mut mac) = Hmac::<sha2::Sha256>::new_from_slice(signing_secret.as_bytes()) else {
return false;
};
mac.update(format!("v0:{timestamp}:{body}").as_bytes());
let expected = format!("v0={}", hex::encode(mac.finalize().into_bytes()));
// Constant-time comparison: do not leak prefix matches.
expected.len() == signature.len()
&& expected
.bytes()
.zip(signature.bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
== 0
}
/// The broker daemon: listens on a unix socket reachable only by the /// The broker daemon: listens on a unix socket reachable only by the
/// server process (never mounted into agent sandboxes). /// server process (never mounted into agent sandboxes).
pub struct BrokerServer { pub struct BrokerServer {
@@ -76,6 +108,18 @@ impl BrokerServer {
Request::SecretKind { secret_id } => Ok(Response::SecretKind { Request::SecretKind { secret_id } => Ok(Response::SecretKind {
kind: store.kind(secret_id).await?, kind: store.kind(secret_id).await?,
}), }),
Request::VerifySlackSignature {
secret_id,
timestamp,
body,
signature,
} => {
let secret = store.reveal_internal(secret_id).await?;
let signing = credential_field(&secret, "signing_secret");
let valid =
crate::server::slack_signature_valid(&signing, &timestamp, &body, &signature);
Ok(Response::Verified { valid })
}
Request::InvokeHttp { Request::InvokeHttp {
approval_id, approval_id,
secret_id, secret_id,
@@ -94,9 +138,10 @@ impl BrokerServer {
.map_err(|_| BrokerError::GrantRefused)?; .map_err(|_| BrokerError::GrantRefused)?;
let credential = store.reveal_internal(secret_id).await?; let credential = store.reveal_internal(secret_id).await?;
let bearer = credential_field(&credential, "bot_token");
let response = reqwest::Client::new() let response = reqwest::Client::new()
.post(&url) .post(&url)
.bearer_auth(credential) .bearer_auth(bearer)
.json(&body) .json(&body)
.send() .send()
.await .await
+13
View File
@@ -77,3 +77,16 @@ events = [
events = [ events = [
{ type = "text", text = " Posted to #general." }, { type = "text", text = " Posted to #general." },
] ]
[[scenario]]
marker = "[[scenario:mention]]"
[[scenario.turns]]
events = [
{ type = "tool_use", name = "slack.post", input = { channel = "#general", text = "On it!" } },
]
[[scenario.turns]]
events = [
{ type = "text", text = " Replied in Slack." },
]
@@ -35,7 +35,12 @@ export default function SlackApp({ agent }: { agent: Agent }) {
clawId: agent.id, clawId: agent.id,
provider: "slack", provider: "slack",
authType: "keys", authType: "keys",
secret: data.get("token"), // One broker-held secret carrying both credentials; the signing
// secret never leaves the broker (it verifies inbound mentions).
secret: JSON.stringify({
bot_token: data.get("token"),
signing_secret: data.get("signing_secret"),
}),
}), }),
}); });
setSaving(false); setSaving(false);
@@ -80,6 +85,16 @@ export default function SlackApp({ agent }: { agent: Agent }) {
even by admins. even by admins.
</span> </span>
</label> </label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Signing secret
<input
name="signing_secret"
type="password"
required
placeholder="Slack app signing secret"
className="rounded-(--radius) border border-input bg-subtle px-2 py-1.5 text-sm text-foreground outline-none focus:border-accent"
/>
</label>
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
+41
View File
@@ -1,3 +1,5 @@
import { createHmac } from "node:crypto";
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
// P4 exit criterion (spec §17): connect an app, and Slack outbound is // P4 exit criterion (spec §17): connect an app, and Slack outbound is
@@ -29,6 +31,7 @@ test("connect Slack, then an outbound post is gated and broker-executed", async
await panel.getByRole("button", { name: "Slack" }).click(); await panel.getByRole("button", { name: "Slack" }).click();
await panel.getByRole("button", { name: "Connect Slack" }).first().click(); await panel.getByRole("button", { name: "Connect Slack" }).first().click();
await panel.getByLabel(/Bot token/).fill("xoxb-e2e-token"); await panel.getByLabel(/Bot token/).fill("xoxb-e2e-token");
await panel.getByLabel(/Signing secret/).fill("e2e-signing-secret");
await panel.getByRole("button", { name: "Connect Slack" }).click(); await panel.getByRole("button", { name: "Connect Slack" }).click();
await expect(panel.getByText("Slack is connected")).toBeVisible(); await expect(panel.getByText("Slack is connected")).toBeVisible();
await panel.getByRole("button", { name: "Close computer" }).click(); await panel.getByRole("button", { name: "Close computer" }).click();
@@ -58,4 +61,42 @@ test("connect Slack, then an outbound post is gated and broker-executed", async
// The audit trail shows the decision. // The audit trail shows the decision.
await page.getByRole("link", { name: "Approvals" }).click(); await page.getByRole("link", { name: "Approvals" }).click();
await expect(page.getByText(/All clear/)).toBeVisible(); await expect(page.getByText(/All clear/)).toBeVisible();
// INBOUND: a signed @mention drives a run whose reply is gated too.
const mention = JSON.stringify({
type: "event_callback",
event: { type: "app_mention", text: "reply please [[scenario:mention]]" },
});
const timestamp = "1234567890";
const signature =
"v0=" +
createHmac("sha256", "e2e-signing-secret")
.update(`v0:${timestamp}:${mention}`)
.digest("hex");
const inbound = await request.post("http://127.0.0.1:8080/api/slack/events", {
headers: {
"x-slack-request-timestamp": timestamp,
"x-slack-signature": signature,
"content-type": "application/json",
},
data: mention,
});
expect(inbound.status()).toBe(200);
// The reply lands in the approval queue (asynchronously — the mention
// spawns the run); poll the page until the card shows up.
const inboundCard = page.getByRole("region", { name: "Review and approve" });
await expect(async () => {
await page.reload();
await expect(inboundCard).toBeVisible({ timeout: 1000 });
}).toPass({ timeout: 15000 });
await expect(inboundCard).toContainText("Post to Slack #general");
await inboundCard.getByRole("button", { name: "Approve" }).click();
await expect(page.getByText(/All clear/)).toBeVisible();
await expect
.poll(async () => {
const res = await request.get("http://127.0.0.1:8080/__slack/posts");
return ((await res.json()) as { text: string }[]).map((p) => p.text);
})
.toContain("On it!");
}); });