3 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 4.8 bf4ef4c4bf fix(missions): reap all mission resources on delete (no hanging claws/files)
ci / gates (push) Successful in 24s
ci / rust (push) Failing after 10s
ci / frontend (push) Successful in 27s
ci / e2e (push) Skipped
ci / publish (push) Skipped
DELETE /api/missions/{id} was a bare `DELETE FROM missions` relying on FK
cascades that only cover mission-owned tables. Everything the mission
provisioned leaked: per-mission runtime container, host workspace dir,
teams (created lifecycle=permanent, so no cascade + skipped by the
ephemeral-teardown path), and every claw's ZeroClaw config, .brain files,
and DB rows. Observed live with 0 missions in the DB: 174 orphaned gateway
claw configs, 7 orphaned teams, 31 agents, 39 .brain files, 6 workspace
dirs, a 4-day-old orphaned container, and 123 detached topology_runs.

delete() now calls reap_mission_resources() before the row delete:
- resolve the mission's teams (mission_teams) → claws (team_members)
- per claw: deprovision_claw (gateway) + rm .brain files + hard_purge (DB),
  reusing the manual agent-reap pattern in routes/claws.rs
- delete the permanent-lifecycle teams (team_members cascades)
- delete the mission's topology_runs (else they linger with mission_id
  nulled by the cascade and accumulate)
- teardown_container(), now extended to also rm the /mission/repo workspace
  dir and tolerate an already-gone container (idempotent for the sweeper +
  delete paths)

Runtime-side steps are best-effort (Postgres authoritative; fleet sweeper
reconciles daemon config); DB purges are logged on failure but never block.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-28 09:28:56 +02:00
Omar SobhandClaude Opus 4.8 11e1379c5f fix(build): normalize /etc/clawmates seed-dir perms for the nonroot user
The server COPYs templates/ and skills/ then drops to USER 65532. When the
build context arrives with mode-700 dirs (e.g. rsync -a preserving a dev's
local perms), COPY bakes 700 into the image and the nonroot runtime user
can't read them — the skills/team-template builtin seed silently skips
("Permission denied (os error 13)"). chmod -R a+rX after the COPYs makes
the seed dirs readable regardless of source perms.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-28 09:07:42 +02:00
Omar SobhandClaude Opus 4.8 34409bca0c fix(missions): grant coding tools + pin claw workspace to /mission/repo
Mission agents were burning ~275K tokens producing nothing: the coder had
only file_read and its workspace was the empty ephemeral sandbox, so it
dumped a full spec inline instead of writing files. Two root causes:

1. Risk-profile allowlists used pre-0.8 tool names. `coding_readwrite`
   allow-listed `file_write` (renamed to `file_edit` in ZeroClaw 0.8, and
   `file_write` now refuses on ephemeral workspaces) and omitted file_edit
   / content_search / glob_search / git_operations — the exact tools the
   phase prompt tells agents to use. Since allowed_tools is a strict
   allowlist, agents were effectively read-only. Documents the correct
   profiles in agent.config.example.toml (they only lived in host config;
   the live runtime profiles were corrected via its config API).

2. workspace.path never got set. `agents.<alias>.workspace.path` is an
   Option<PathBuf> the ZeroClaw Configurable macro skips from prop
   enumeration, so provision_claw's set_prop always 404'd and the whole
   call errored into a swallowed eprintln. Removes the dead set_prop and
   pins the workspace out-of-band: MissionRuntimeProvisioner::
   pin_agent_workspaces patches the shared config file on the per-mission
   container (format-preserving via toml_edit, atomic temp+mv); the daemon
   applies it on the same reload that surfaces the freshly-provisioned
   claws. Covered by unit tests for the TOML stamp.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-28 08:40:18 +02:00
9 changed files with 402 additions and 40 deletions
Generated
+1
View File
@@ -974,6 +974,7 @@ dependencies = [
"tokio",
"tokio-tungstenite 0.26.2",
"toml",
"toml_edit",
"tower-http",
"urlencoding",
"uuid",
+1
View File
@@ -9,6 +9,7 @@ publish.workspace = true
[dependencies]
getrandom = "0.2"
toml = "0.8"
toml_edit = "0.22"
serde_yaml = "0.9"
hex = "0.4"
hmac = "0.12"
+36 -14
View File
@@ -159,6 +159,7 @@ pub async fn on_launch(
let provisioner = RuntimeProvisioner::from_env();
let mut first_team_id: Option<Uuid> = None;
let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new();
for (purpose, template_id) in &picks {
let template = cm_db::repo::team_templates::get(pool, *template_id)
.await
@@ -176,6 +177,7 @@ pub async fn on_launch(
&template,
&team_name,
"claude-sonnet-5",
&mut provisioned_claws,
)
.await?;
// Record (mission, team, purpose) in mission_teams so the Team
@@ -201,6 +203,29 @@ pub async fn on_launch(
.await
.map_err(|e| format!("bind team on mission: {e}"))?;
// Pin every provisioned claw's workspace to /mission/repo so
// file_edit / content_search / glob_search / git_operations operate
// on the mission's checked-out repo instead of the empty per-agent
// sandbox. This CANNOT go through the config prop API (workspace.path
// is a PathBuf the prop-schema won't expose — see provision_claw), so
// we patch the shared config file directly on the per-mission runtime
// container. The daemon picks it up on the same reload that surfaces
// the freshly-provisioned claws for the run. Non-fatal: without the
// pin, agents still write (to the sandbox) but the committer can't
// find the changes in /mission/repo.
if !provisioned_claws.is_empty() {
if let Some(mp) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() {
if let Err(e) = mp
.pin_agent_workspaces(mission_id, &provisioned_claws, "/mission/repo")
.await
{
eprintln!(
"mission_orchestrator: pin workspaces for mission {mission_id} failed (continuing): {e}"
);
}
}
}
// Herdr second-runtime: if runtime_kind='local_herdr', spawn a
// pane on target_node running the first available local CLI.
// Non-fatal on failure — the operator sees the error in server
@@ -253,6 +278,7 @@ async fn mint_team_from_template(
template: &TeamTemplateDetail,
team_name: &str,
default_model: &str,
provisioned_claws: &mut Vec<cm_domain::AgentId>,
) -> Result<Uuid, String> {
// Build the topology graph from role slots so the team's `graph`
// NOT NULL column is satisfied + downstream topology executors
@@ -332,24 +358,20 @@ async fn mint_team_from_template(
// scout/researcher, coding_readwrite for coder/tester/committer,
// etc.). Passing "toolfree" — the old default — left every
// agent with zero tools regardless of what its prompt asked for.
//
// Workspace pinning to /mission/repo is NOT done here (the
// config prop-schema can't set workspace.path — see
// provision_claw's doc); the caller pins the collected claws
// out-of-band via MissionRuntimeProvisioner::pin_agent_workspaces.
if let Some(p) = provisioner {
// /mission/repo is the bind-mount path inside the per-mission
// runtime container (see mission_runtime::ensure_container).
// Pinning workspace.path there lets file_edit / glob_search /
// content_search actually operate on the mission's checked-out
// repo instead of the empty per-agent sandbox.
if let Err(e) = p
.provision_claw(
claw_id,
default_model,
&template.template.risk_profile,
Some("/mission/repo"),
)
match p
.provision_claw(claw_id, default_model, &template.template.risk_profile)
.await
{
eprintln!(
Ok(_) => provisioned_claws.push(agent.id),
Err(e) => eprintln!(
"mission_orchestrator: provision claw {claw_id} failed (continuing): {e}"
);
),
}
}
+217 -3
View File
@@ -32,6 +32,7 @@ use bollard::query_parameters::{
CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions,
};
use bollard::Docker;
use base64::Engine;
use futures::StreamExt;
use std::collections::HashMap;
use uuid::Uuid;
@@ -343,6 +344,131 @@ impl MissionRuntimeProvisioner {
}
extract_pairing_code_from_json(&buf)
}
/// Stamp `[agents.<alias>.workspace] path = "<workspace_path>"` into the
/// shared runtime config for each provisioned claw, by editing the
/// config file directly on the per-mission container. This is the
/// out-of-band path for workspace pinning: the ZeroClaw config prop API
/// cannot set `workspace.path` (an `Option<PathBuf>` the `Configurable`
/// macro skips from prop enumeration), so `provision_claw` leaves it
/// unset and we stamp it here. The daemon applies the change on the
/// same config reload that surfaces the freshly-provisioned claws for
/// the run.
///
/// Concurrency caveat (mirrors the seed-dir note above): the config
/// file is shared across the persistent runtime and every per-mission
/// daemon, so this read-modify-write can race a provision from another
/// mission launching at the same instant. Missions launch one at a
/// time in practice; the durable fix is per-mission config isolation.
pub async fn pin_agent_workspaces(
&self,
mission_id: Uuid,
claws: &[cm_domain::AgentId],
workspace_path: &str,
) -> Result<(), String> {
if claws.is_empty() {
return Ok(());
}
const CONFIG_PATH: &str = "/zeroclaw-data/.zeroclaw/config.toml";
let name = container_name(mission_id);
let raw = self
.exec_capture(&name, vec!["cat".into(), CONFIG_PATH.into()])
.await?;
let aliases: Vec<String> = claws
.iter()
.map(|c| crate::runtime_provision::claw_alias(c.as_uuid()))
.collect();
let (edited, pinned) = stamp_workspace_paths(&raw, &aliases, workspace_path)?;
if pinned == 0 {
return Ok(());
}
let b64 = base64::engine::general_purpose::STANDARD.encode(edited.as_bytes());
// Decode to a sibling temp then atomically move over the live file,
// so a partial write can never leave the daemon with truncated TOML.
let script = format!(
"printf %s '{b64}' | base64 -d > {CONFIG_PATH}.tmp && mv {CONFIG_PATH}.tmp {CONFIG_PATH}"
);
let out = self
.exec_capture(&name, vec!["sh".into(), "-c".into(), script])
.await?;
if !out.trim().is_empty() {
return Err(format!("write runtime config.toml: {out}"));
}
eprintln!(
"mission_runtime: pinned {pinned} workspace(s) → {workspace_path} for mission {mission_id}"
);
Ok(())
}
/// Run a command in the mission container and return its combined
/// stdout+stderr as a String. Used for small config round-trips.
async fn exec_capture(&self, name: &str, cmd: Vec<String>) -> Result<String, String> {
let exec = self
.docker
.create_exec(
name,
CreateExecOptions {
cmd: Some(cmd),
attach_stdout: Some(true),
attach_stderr: Some(true),
..Default::default()
},
)
.await
.map_err(|e| format!("create_exec on {name}: {e}"))?;
let started = self
.docker
.start_exec(&exec.id, None)
.await
.map_err(|e| format!("start_exec on {name}: {e}"))?;
let StartExecResults::Attached { mut output, .. } = started else {
return Err(format!("exec on {name} returned a detached result"));
};
let mut buf = String::new();
while let Some(chunk) = output.next().await {
match chunk {
Ok(c) => buf.push_str(&c.to_string()),
Err(e) => return Err(format!("exec output stream on {name}: {e}")),
}
}
Ok(buf)
}
}
/// Format-preserving stamp of `[agents.<alias>.workspace] path = "<path>"`
/// for each alias present in `raw`. Keeps the operator's comments, ordering,
/// and every untouched byte intact; only the `path` keys change. Aliases not
/// already present are skipped (never fabricated — a bare agent table would
/// drop that agent's model/risk_profile/bundles). Returns the edited document
/// and how many agents were pinned.
fn stamp_workspace_paths(
raw: &str,
aliases: &[String],
workspace_path: &str,
) -> Result<(String, usize), String> {
let mut doc = raw
.parse::<toml_edit::DocumentMut>()
.map_err(|e| format!("parse runtime config.toml: {e}"))?;
let Some(agents) = doc.get_mut("agents").and_then(|i| i.as_table_like_mut()) else {
return Err("runtime config has no [agents] table".to_string());
};
let mut pinned = 0usize;
for alias in aliases {
let Some(agent) = agents.get_mut(alias).and_then(|i| i.as_table_like_mut()) else {
eprintln!("mission_runtime: pin skip — {alias} absent from config");
continue;
};
if agent.get("workspace").is_none() {
agent.insert("workspace", toml_edit::Item::Table(toml_edit::Table::new()));
}
if let Some(ws) = agent.get_mut("workspace").and_then(|i| i.as_table_like_mut()) {
ws.insert("path", toml_edit::value(workspace_path));
pinned += 1;
}
}
Ok((doc.to_string(), pinned))
}
/// Parse the JSON `{ "pairing_code": "NNNNNN", ... }` body from the
@@ -356,10 +482,14 @@ fn extract_pairing_code_from_json(body: &str) -> Option<String> {
}
impl MissionRuntimeProvisioner {
/// Force-remove the mission's runtime container. Idempotent.
/// Force-remove the mission's runtime container AND its host workspace
/// dir (the `/mission/repo` checkout bind source). Idempotent: a missing
/// container or dir is not an error — this is called both by the terminal
/// sweeper and by mission delete, where the container may already be gone.
pub async fn teardown_container(&self, mission_id: Uuid) -> Result<(), String> {
let name = container_name(mission_id);
self.docker
if let Err(e) = self
.docker
.remove_container(
&name,
Some(RemoveContainerOptions {
@@ -368,7 +498,21 @@ impl MissionRuntimeProvisioner {
}),
)
.await
.map_err(|e| format!("remove mission runtime container: {e}"))?;
{
// 404 (already gone) is fine; anything else is worth surfacing.
let msg = e.to_string();
if !msg.contains("No such container") && !msg.contains("404") {
return Err(format!("remove mission runtime container: {e}"));
}
}
// Remove the per-mission workspace dir (repo checkout + scratch). This
// path is bind-mounted into cm-api, so we can reap it directly.
let mission_dir = format!("{MISSIONS_HOST_ROOT}/{mission_id}");
if let Err(e) = tokio::fs::remove_dir_all(&mission_dir).await {
if e.kind() != std::io::ErrorKind::NotFound {
eprintln!("mission_runtime: rm workspace dir {mission_dir}: {e}");
}
}
Ok(())
}
}
@@ -441,6 +585,76 @@ async fn sweep_once(pool: &sqlx::PgPool, grace: std::time::Duration) -> Result<(
mod tests {
use super::*;
const SAMPLE_CONFIG: &str = r#"# top comment
[agents.claw_a]
model_provider = "anthropic.default"
risk_profile = "coding_readwrite"
mcp_bundles = ["clawmates_door"]
[agents.claw_a.workspace]
unrestricted_filesystem = false
[agents.claw_b]
risk_profile = "research_readonly"
[risk_profiles.coding_readwrite]
# keep this comment
allowed_tools = ["file_read", "file_edit"]
"#;
#[test]
fn stamp_pins_path_and_preserves_existing_workspace_fields() {
let (out, n) = stamp_workspace_paths(
SAMPLE_CONFIG,
&["claw_a".to_string()],
"/mission/repo",
)
.unwrap();
assert_eq!(n, 1);
assert!(out.contains(r#"path = "/mission/repo""#));
// The sibling field in the same table is untouched.
assert!(out.contains("unrestricted_filesystem = false"));
// Comments and unrelated sections survive the round-trip.
assert!(out.contains("# top comment"));
assert!(out.contains("# keep this comment"));
assert!(out.contains("[risk_profiles.coding_readwrite]"));
}
#[test]
fn stamp_creates_workspace_table_when_absent() {
let (out, n) =
stamp_workspace_paths(SAMPLE_CONFIG, &["claw_b".to_string()], "/mission/repo").unwrap();
assert_eq!(n, 1);
// claw_b had no [workspace] table; it now has one with the path.
let doc = out.parse::<toml_edit::DocumentMut>().unwrap();
assert_eq!(
doc["agents"]["claw_b"]["workspace"]["path"].as_str(),
Some("/mission/repo")
);
}
#[test]
fn stamp_skips_absent_aliases_without_fabricating_them() {
let (out, n) =
stamp_workspace_paths(SAMPLE_CONFIG, &["claw_missing".to_string()], "/mission/repo")
.unwrap();
assert_eq!(n, 0);
assert!(!out.contains("claw_missing"));
}
#[test]
fn stamp_is_idempotent_overwriting_a_prior_path() {
let once =
stamp_workspace_paths(SAMPLE_CONFIG, &["claw_a".to_string()], "/old/path").unwrap().0;
let (twice, n) =
stamp_workspace_paths(&once, &["claw_a".to_string()], "/mission/repo").unwrap();
assert_eq!(n, 1);
assert!(twice.contains(r#"path = "/mission/repo""#));
assert!(!twice.contains("/old/path"));
// Exactly one path key for claw_a (no duplication).
assert_eq!(twice.matches("path = ").count(), 1);
}
#[test]
fn container_name_is_stable_and_prefixed() {
let id = Uuid::parse_str("019f84a0-f2a2-7bd0-be9b-86713ec73693").unwrap();
+97 -2
View File
@@ -353,14 +353,109 @@ pub async fn delete(
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
let deleted =
cm_db::repo::missions::delete(&state.pool, id, user.workspace_id.as_uuid()).await?;
let ws = user.workspace_id.as_uuid();
// Verify the mission exists in this workspace before we start reaping.
let exists: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM missions WHERE id = $1 AND workspace_id = $2")
.bind(id)
.bind(ws)
.fetch_optional(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
if exists.is_none() {
return Err(ApiError::NotFound);
}
// Reap every resource the mission provisioned BEFORE the DB delete, so
// nothing is left hanging. Runtime-side steps are best-effort (Postgres
// is authoritative; the daemon config is a cache the fleet sweeper can
// reconcile) — a failure logs and continues rather than blocking delete.
reap_mission_resources(&state, id).await;
let deleted = cm_db::repo::missions::delete(&state.pool, id, ws).await?;
if deleted == 0 {
return Err(ApiError::NotFound);
}
Ok(Json(serde_json::json!({ "deleted": true })))
}
/// Tear down all resources a mission created: its per-mission runtime
/// container + workspace dir, every claw (ZeroClaw config, `.brain` files,
/// and all DB rows via `hard_purge`), the (permanent-lifecycle) teams, and
/// its topology runs. Called before the `missions` row is deleted so the
/// `mission_teams` junction is still resolvable. Best-effort throughout.
async fn reap_mission_resources(state: &AppState, mission_id: Uuid) {
// 1. Resolve the mission's teams, then their claws.
let team_ids: Vec<Uuid> =
sqlx::query_scalar("SELECT team_id FROM mission_teams WHERE mission_id = $1")
.bind(mission_id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let claw_ids: Vec<Uuid> = if team_ids.is_empty() {
Vec::new()
} else {
sqlx::query_scalar(
"SELECT DISTINCT claw_id FROM team_members WHERE team_id = ANY($1)",
)
.bind(&team_ids)
.fetch_all(&state.pool)
.await
.unwrap_or_default()
};
// 2. Reap each claw: ZeroClaw config → .brain files → all DB rows.
let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env();
for cid in &claw_ids {
if let Some(p) = &provisioner {
let _ = p.deprovision_claw(*cid).await;
}
let brain = crate::routes::claws::brain_dir();
let _ = std::fs::remove_file(brain.join(format!("claw_{cid}.h5")));
let _ = std::fs::remove_file(brain.join(format!("claw_{cid}.h5.onion")));
if let Err(e) =
cm_db::repo::agents::hard_purge(&state.pool, cm_domain::AgentId::from(*cid)).await
{
eprintln!("missions::delete: hard_purge claw {cid} failed (continuing): {e}");
}
}
// 3. Delete the (permanent-lifecycle) teams — no mission FK cascades them.
// team_members cascades from teams.
if !team_ids.is_empty() {
if let Err(e) = sqlx::query("DELETE FROM teams WHERE id = ANY($1)")
.bind(&team_ids)
.execute(&state.pool)
.await
{
eprintln!("missions::delete: delete teams for {mission_id} failed (continuing): {e}");
}
}
// 4. Delete this mission's topology runs (else they linger with
// mission_id nulled by the cascade and accumulate forever).
if let Err(e) = sqlx::query("DELETE FROM topology_runs WHERE mission_id = $1")
.bind(mission_id)
.execute(&state.pool)
.await
{
eprintln!("missions::delete: delete topology_runs for {mission_id} failed (continuing): {e}");
}
// 5. Tear down the per-mission runtime container + its workspace dir.
if let Some(mp) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() {
if let Err(e) = mp.teardown_container(mission_id).await {
eprintln!("missions::delete: teardown container for {mission_id} failed (continuing): {e}");
}
}
eprintln!(
"missions::delete: reaped {} claw(s), {} team(s) for mission {mission_id}",
claw_ids.len(),
team_ids.len()
);
}
#[derive(Debug, Deserialize)]
pub struct HerdrDispatchRequest {
pub cli: String,
+3 -4
View File
@@ -130,11 +130,10 @@ pub(crate) async fn build_team_with_lifecycle(
.await?;
let claw_id = agent.id.as_uuid();
let risk = RuntimeProvisioner::default_risk_profile_for_role(&m.role);
// No workspace override on the ad-hoc team-wizard path — those
// teams aren't mission-bound so they use the default per-agent
// workspace under <install>/agents/<alias>/workspace/.
// Ad-hoc team-wizard teams aren't mission-bound, so they use the
// default per-agent workspace under <install>/agents/<alias>/workspace/.
provisioner
.provision_claw(claw_id, &m.model, risk, None)
.provision_claw(claw_id, &m.model, risk)
.await
.map_err(|e| {
eprintln!("teams: provision claw {claw_id} failed: {e}");
+14 -17
View File
@@ -134,16 +134,21 @@ impl RuntimeProvisioner {
/// Create `claw_<id>` as a live runtime agent bound to `model_alias`,
/// `risk_profile` (from the team template — controls which tools this
/// agent gets: `toolfree` = nothing, `research_readonly` = file_read
/// only, `coding_readwrite` = file_read + file_write + shell, etc.),
/// and the `clawmates_door` MCP bundle.
/// agent gets: `toolfree` = nothing, `research_readonly` = file_read +
/// content_search + glob_search, `coding_readwrite` = adds file_edit +
/// git_operations + shell, etc.; see the `[risk_profiles.*]` allowlists
/// in `deploy/clawmates-runtime/agent.config.example.toml`), and the
/// `clawmates_door` MCP bundle.
///
/// `workspace_path`, when Some, pins the agent's per-agent workspace
/// dir via `[agents.<alias>.workspace.path]`. In per-mission runtime
/// containers this is `/mission/repo` so `file_edit` / `content_search`
/// / `glob_search` operate on the mission's checked-out repo instead
/// of the default `<install>/agents/<alias>/workspace/` sandbox
/// (which the agent can't populate with the mission's source files).
/// NOTE ON WORKSPACE PINNING: `[agents.<alias>.workspace.path]` is an
/// `Option<PathBuf>` field that the ZeroClaw config prop-schema does NOT
/// expose as settable (the `Configurable` macro skips `PathBuf` from
/// property enumeration — `zeroclaw-macros/src/lib.rs`), so a
/// `set_prop("agents.<alias>.workspace.path", …)` here would always 404
/// with `path_not_found` and fail the whole provision. Per-mission
/// workspace pinning is therefore done out-of-band by
/// `MissionRuntimeProvisioner::pin_agent_workspaces`, which patches the
/// shared config file directly for the mission's claws.
///
/// Idempotent on the create step.
pub async fn provision_claw(
@@ -151,7 +156,6 @@ impl RuntimeProvisioner {
claw_id: Uuid,
model: &str,
risk_profile: &str,
workspace_path: Option<&str>,
) -> Result<String, String> {
let alias = claw_alias(claw_id);
let model_alias = provider_alias_for(model);
@@ -189,13 +193,6 @@ impl RuntimeProvisioner {
serde_json::json!(["clawmates_door"]),
)
.await?;
if let Some(path) = workspace_path {
self.set_prop(
&format!("agents.{alias}.workspace.path"),
serde_json::json!(path),
)
.await?;
}
Ok(alias)
}
@@ -125,6 +125,34 @@ level = "full"
allowed_tools = []
excluded_tools = ["shell", "file_read", "file_write", "http_request", "browser", "composio"]
# Writable coding-loop profile (coder/tester/committer/engineer roles). The
# `allowed_tools` list is a STRICT allowlist — a tool must be named here to be
# callable. These MUST be the current ZeroClaw 0.8+ tool names:
# file_edit — create/overwrite/patch (the real write tool; `file_write`
# was renamed and now REFUSES on ephemeral workspaces, so a
# stale `file_write` entry silently leaves agents read-only)
# content_search — grep across the workspace
# glob_search — find files by glob
# git_operations — git status/add/commit/diff/log
# Regression guard: if you ever see an agent report "I only have file_read" and
# burn tokens dumping code inline, this list drifted back to pre-0.8 names.
[risk_profiles.coding_readwrite]
level = "full"
allowed_tools = ["file_read", "file_edit", "content_search", "glob_search", "git_operations", "shell"]
excluded_tools = ["http_request", "browser", "composio"]
# Read-only research profile (scout/researcher/reviewer/planner roles).
[risk_profiles.research_readonly]
level = "full"
allowed_tools = ["file_read", "content_search", "glob_search"]
excluded_tools = ["shell", "file_write", "http_request", "browser", "composio"]
# Read-only research + public web (papers, docs). Still no shell / no write.
[risk_profiles.research_web_readonly]
level = "full"
allowed_tools = ["file_read", "content_search", "glob_search", "web_search", "web_fetch"]
excluded_tools = ["shell", "file_write", "http_request", "browser", "composio"]
# The role-cast. node.role → agent alias is configured Clawmates-side via
# ZEROCLAW_AGENT_MAP (e.g. "analyst=researcher"); `scout` is the default
# fallback (ZEROCLAW_DEFAULT_AGENT) for any unmapped role.
+5
View File
@@ -58,5 +58,10 @@ COPY --from=builder /clawmates-server /usr/local/bin/clawmates-server
# Builtin templates (team + workflow). Loader upserts them on boot.
COPY templates /etc/clawmates/templates
COPY skills /etc/clawmates/skills
# Normalize perms: the source dirs may arrive mode 700 (e.g. rsync -a
# preserving a developer's local dir perms), which would leave the
# nonroot runtime user unable to read them and silently skip the builtin
# skills/team-template seed. a+rX = dirs traversable, files readable.
RUN chmod -R a+rX /etc/clawmates/templates /etc/clawmates/skills
USER 65532
ENTRYPOINT ["/usr/local/bin/clawmates-server"]