- 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]>
236 lines
7.2 KiB
Rust
236 lines
7.2 KiB
Rust
//! File-drive tools end to end: write/list run ungated; delete is a §15
|
|
//! gated category that executes only after approval.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use tc_domain::{
|
|
AccessPolicy, Agent, AgentId, AgentStatus, FileDrive, Role, RunState, User, UserId, Workspace,
|
|
WorkspaceId,
|
|
};
|
|
use tc_files::LocalBlobStore;
|
|
use tc_llm::ScriptedProvider;
|
|
use tc_runtime::{RunEventBody, Runtime, RuntimeConfig};
|
|
use tc_safety::{approvals, Decision};
|
|
|
|
const SCENARIOS: &str = r##"
|
|
[[scenario]]
|
|
marker = "[[scenario:write-report]]"
|
|
|
|
[[scenario.turns]]
|
|
events = [
|
|
{ type = "tool_use", name = "files.write", input = { path = "reports/q2.md", content = "# Q2\nRevenue up 14%." } },
|
|
]
|
|
|
|
[[scenario.turns]]
|
|
events = [
|
|
{ type = "tool_use", name = "files.list", input = {} },
|
|
]
|
|
|
|
[[scenario.turns]]
|
|
events = [
|
|
{ type = "text", text = "Report saved and verified." },
|
|
]
|
|
|
|
[[scenario]]
|
|
marker = "[[scenario:delete-report]]"
|
|
|
|
[[scenario.turns]]
|
|
events = [
|
|
{ type = "tool_use", name = "files.delete", input = { path = "reports/q2.md" } },
|
|
]
|
|
|
|
[[scenario.turns]]
|
|
events = [
|
|
{ type = "text", text = "Deletion handled." },
|
|
]
|
|
"##;
|
|
|
|
async fn seeded(pool: &sqlx::PgPool) -> (Workspace, User, Agent) {
|
|
let workspace = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Acme".into(),
|
|
plan: "team".into(),
|
|
};
|
|
tc_db::repo::workspaces::insert(pool, &workspace)
|
|
.await
|
|
.unwrap();
|
|
let owner = User {
|
|
id: UserId::new(),
|
|
workspace_id: workspace.id,
|
|
email: format!("{}@acme.test", UserId::new()),
|
|
role: Role::Owner,
|
|
display_name: "Owner".into(),
|
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
|
};
|
|
tc_db::repo::users::insert(pool, &owner).await.unwrap();
|
|
let agent = Agent {
|
|
id: AgentId::new(),
|
|
workspace_id: workspace.id,
|
|
name: "Scout".into(),
|
|
job_title: "Analyst".into(),
|
|
system_prompt: String::new(),
|
|
avatar: String::new(),
|
|
accent: String::new(),
|
|
wallpaper: String::new(),
|
|
managed_by: owner.id,
|
|
status: AgentStatus::Online,
|
|
};
|
|
tc_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
|
|
.await
|
|
.unwrap();
|
|
(workspace, owner, agent)
|
|
}
|
|
|
|
fn runtime(pool: sqlx::PgPool) -> (Runtime, std::path::PathBuf) {
|
|
let root = std::env::temp_dir().join(format!("tc-files-test-{}", uuid::Uuid::now_v7()));
|
|
let rt = Runtime::with_blob_store(
|
|
pool,
|
|
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
|
|
RuntimeConfig::basic("scripted", 1024),
|
|
Arc::new(LocalBlobStore::new(root.clone())),
|
|
);
|
|
(rt, root)
|
|
}
|
|
|
|
async fn run_to_end(
|
|
mut rx: tokio::sync::broadcast::Receiver<tc_runtime::RunEventEnvelope>,
|
|
) -> Vec<tc_runtime::RunEventEnvelope> {
|
|
let mut events = Vec::new();
|
|
while let Ok(envelope) = rx.recv().await {
|
|
let done = matches!(
|
|
envelope.event,
|
|
RunEventBody::RunCompleted { .. }
|
|
| RunEventBody::Error { .. }
|
|
| RunEventBody::RunSuspended { .. }
|
|
);
|
|
events.push(envelope);
|
|
if done {
|
|
break;
|
|
}
|
|
}
|
|
events
|
|
}
|
|
|
|
async fn wait_completed(pool: &sqlx::PgPool, run_id: uuid::Uuid) {
|
|
for _ in 0..100 {
|
|
let run = tc_db::repo::runs::get(pool, run_id).await.unwrap();
|
|
if run.state == RunState::Completed {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
panic!("run never completed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_and_list_run_ungated_and_persist_real_blobs() {
|
|
let pool = tc_testkit::test_pool().await;
|
|
let (workspace, _, agent) = seeded(&pool).await;
|
|
let (rt, blob_root) = runtime(pool.clone());
|
|
let session = tc_db::repo::sessions::create(&pool, agent.id, workspace.id, "Files")
|
|
.await
|
|
.unwrap();
|
|
|
|
let started = rt
|
|
.send_message(session.id, "save it [[scenario:write-report]]")
|
|
.await
|
|
.unwrap();
|
|
let events = run_to_end(started.events).await;
|
|
assert!(matches!(
|
|
events.last().unwrap().event,
|
|
RunEventBody::RunCompleted { .. }
|
|
));
|
|
|
|
// The node row exists with the right drive and size.
|
|
let nodes = tc_db::repo::files::list(&pool, workspace.id, FileDrive::Documents, agent.id)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(nodes.len(), 1);
|
|
assert_eq!(nodes[0].path, "reports/q2.md");
|
|
assert_eq!(nodes[0].size, "# Q2\nRevenue up 14%.".len() as i64);
|
|
|
|
// The blob content is really on disk under the agent's scope.
|
|
let blob_path = blob_root.join(&nodes[0].blob_ref);
|
|
assert_eq!(
|
|
std::fs::read_to_string(blob_path).unwrap(),
|
|
"# Q2\nRevenue up 14%."
|
|
);
|
|
|
|
// files.list saw it too (second turn output recorded as a step).
|
|
let history = tc_db::repo::messages::history(&pool, session.id)
|
|
.await
|
|
.unwrap();
|
|
let steps = &history.last().unwrap().steps;
|
|
assert_eq!(steps.len(), 2);
|
|
let list_output = steps[1].output.as_ref().unwrap();
|
|
assert_eq!(list_output["files"][0]["path"], "reports/q2.md");
|
|
|
|
// No approvals were involved.
|
|
assert!(approvals::list_pending(&pool, workspace.id)
|
|
.await
|
|
.unwrap()
|
|
.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_is_gated_and_removes_the_file_only_after_approval() {
|
|
let pool = tc_testkit::test_pool().await;
|
|
let (workspace, owner, agent) = seeded(&pool).await;
|
|
let (rt, _blob_root) = runtime(pool.clone());
|
|
let session = tc_db::repo::sessions::create(&pool, agent.id, workspace.id, "Files")
|
|
.await
|
|
.unwrap();
|
|
|
|
// Create the file first.
|
|
let write = rt
|
|
.send_message(session.id, "save it [[scenario:write-report]]")
|
|
.await
|
|
.unwrap();
|
|
run_to_end(write.events).await;
|
|
wait_completed(&pool, write.run_id).await;
|
|
|
|
// Ask for deletion: intercepted with the file_deletion category.
|
|
let delete = rt
|
|
.send_message(session.id, "remove it [[scenario:delete-report]]")
|
|
.await
|
|
.unwrap();
|
|
let events = run_to_end(delete.events).await;
|
|
let (category, preview) = events
|
|
.iter()
|
|
.find_map(|e| match &e.event {
|
|
RunEventBody::ApprovalRequired {
|
|
category, preview, ..
|
|
} => Some((category.clone(), preview.clone())),
|
|
_ => None,
|
|
})
|
|
.expect("approval_required");
|
|
assert_eq!(category, "file_deletion");
|
|
assert_eq!(preview["summary"], "Delete file reports/q2.md");
|
|
|
|
// Still there while pending.
|
|
let nodes = tc_db::repo::files::list(&pool, workspace.id, FileDrive::Documents, agent.id)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(nodes.len(), 1);
|
|
|
|
// Approve → the file is gone.
|
|
let pending = approvals::list_pending(&pool, workspace.id).await.unwrap();
|
|
approvals::decide(&pool, pending[0].id, owner.id, Decision::Approve)
|
|
.await
|
|
.unwrap();
|
|
rt.resume_run(tc_safety::ResumeReady {
|
|
run_id: delete.run_id,
|
|
approval_id: pending[0].id,
|
|
approved: true,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
wait_completed(&pool, delete.run_id).await;
|
|
|
|
let nodes = tc_db::repo::files::list(&pool, workspace.id, FileDrive::Documents, agent.id)
|
|
.await
|
|
.unwrap();
|
|
assert!(nodes.is_empty(), "file must be deleted after approval");
|
|
}
|