Files
clawmates/crates/cm-runtime/tests/files_tools.rs
T
Omar SobhandClaude Fable 5 add4f79fed Rebrand: TeamClaw -> Clawmates (clawmates.work)
Full-depth rename per the approved plan; the 'claw' product vocabulary
(claws, /claws routes, clawId, Claw Chat) stays — it is now the brand.

- Display brand: Clawmates (manifest, titles, hero, login/rail logo
  'clawmates'); default host app.clawmates.work; registry
  ghcr.io/clawmates
- Crates tc-* -> cm-* (16 crates + all imports); binaries
  clawmates-server/broker/bundler; images clawmates/*; env prefix
  CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config
  clawmates.toml; helm chart deploy/helm/clawmates with clawmates-*
  resources; db names clawmates*; sockets /run/clawmates; cookie
  cm_session; kind cluster clawmates-test; seccomp node profile
  clawmates-agent-profile.json
- All 9 Playwright brand assertions updated in lockstep; historical
  spec document left untouched as the only remaining 'TeamClaw'
- Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared
  test server clawmates-test-pg, kind cluster recreated with image +
  profile, compose images rebuilt under clawmates/*

Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright
journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and
the clean-room install rehearsal serving the clawmates login page from
a signed bundle of the rebuilt images.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 12:31:25 -05:00

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 cm_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, FileDrive, Role, RunState, User, UserId, Workspace,
WorkspaceId,
};
use cm_files::LocalBlobStore;
use cm_llm::ScriptedProvider;
use cm_runtime::{RunEventBody, Runtime, RuntimeConfig};
use cm_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(),
};
cm_db::repo::workspaces::insert(pool, &workspace)
.await
.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: workspace.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
cm_db::repo::users::insert(pool, &owner).await.unwrap();
let agent = Agent {
id: AgentId::new(),
workspace_id: workspace.id,
name: "Scout".into(),
job_title: "Analyst".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: owner.id,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.unwrap();
(workspace, owner, agent)
}
fn runtime(pool: sqlx::PgPool) -> (Runtime, std::path::PathBuf) {
let root = std::env::temp_dir().join(format!("cm-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<cm_runtime::RunEventEnvelope>,
) -> Vec<cm_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 = cm_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 = cm_testkit::test_pool().await;
let (workspace, _, agent) = seeded(&pool).await;
let (rt, blob_root) = runtime(pool.clone());
let session = cm_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 = cm_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 = cm_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 = cm_testkit::test_pool().await;
let (workspace, owner, agent) = seeded(&pool).await;
let (rt, _blob_root) = runtime(pool.clone());
let session = cm_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 = cm_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(cm_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 = cm_db::repo::files::list(&pool, workspace.id, FileDrive::Documents, agent.id)
.await
.unwrap();
assert!(nodes.is_empty(), "file must be deleted after approval");
}