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]>
This commit is contained in:
Omar Sobh
2026-07-28 08:40:18 +02:00
co-authored by Claude Opus 4.8
parent 6d5e7c87d7
commit 34409bca0c
7 changed files with 279 additions and 35 deletions
Generated
+1
View File
@@ -974,6 +974,7 @@ dependencies = [
"tokio", "tokio",
"tokio-tungstenite 0.26.2", "tokio-tungstenite 0.26.2",
"toml", "toml",
"toml_edit",
"tower-http", "tower-http",
"urlencoding", "urlencoding",
"uuid", "uuid",
+1
View File
@@ -9,6 +9,7 @@ publish.workspace = true
[dependencies] [dependencies]
getrandom = "0.2" getrandom = "0.2"
toml = "0.8" toml = "0.8"
toml_edit = "0.22"
serde_yaml = "0.9" serde_yaml = "0.9"
hex = "0.4" hex = "0.4"
hmac = "0.12" hmac = "0.12"
+36 -14
View File
@@ -159,6 +159,7 @@ pub async fn on_launch(
let provisioner = RuntimeProvisioner::from_env(); let provisioner = RuntimeProvisioner::from_env();
let mut first_team_id: Option<Uuid> = None; let mut first_team_id: Option<Uuid> = None;
let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new();
for (purpose, template_id) in &picks { for (purpose, template_id) in &picks {
let template = cm_db::repo::team_templates::get(pool, *template_id) let template = cm_db::repo::team_templates::get(pool, *template_id)
.await .await
@@ -176,6 +177,7 @@ pub async fn on_launch(
&template, &template,
&team_name, &team_name,
"claude-sonnet-5", "claude-sonnet-5",
&mut provisioned_claws,
) )
.await?; .await?;
// Record (mission, team, purpose) in mission_teams so the Team // Record (mission, team, purpose) in mission_teams so the Team
@@ -201,6 +203,29 @@ pub async fn on_launch(
.await .await
.map_err(|e| format!("bind team on mission: {e}"))?; .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 // Herdr second-runtime: if runtime_kind='local_herdr', spawn a
// pane on target_node running the first available local CLI. // pane on target_node running the first available local CLI.
// Non-fatal on failure — the operator sees the error in server // Non-fatal on failure — the operator sees the error in server
@@ -253,6 +278,7 @@ async fn mint_team_from_template(
template: &TeamTemplateDetail, template: &TeamTemplateDetail,
team_name: &str, team_name: &str,
default_model: &str, default_model: &str,
provisioned_claws: &mut Vec<cm_domain::AgentId>,
) -> Result<Uuid, String> { ) -> Result<Uuid, String> {
// Build the topology graph from role slots so the team's `graph` // Build the topology graph from role slots so the team's `graph`
// NOT NULL column is satisfied + downstream topology executors // 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, // scout/researcher, coding_readwrite for coder/tester/committer,
// etc.). Passing "toolfree" — the old default — left every // etc.). Passing "toolfree" — the old default — left every
// agent with zero tools regardless of what its prompt asked for. // 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 { if let Some(p) = provisioner {
// /mission/repo is the bind-mount path inside the per-mission match p
// runtime container (see mission_runtime::ensure_container). .provision_claw(claw_id, default_model, &template.template.risk_profile)
// 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"),
)
.await .await
{ {
eprintln!( Ok(_) => provisioned_claws.push(agent.id),
Err(e) => eprintln!(
"mission_orchestrator: provision claw {claw_id} failed (continuing): {e}" "mission_orchestrator: provision claw {claw_id} failed (continuing): {e}"
); ),
} }
} }
+196
View File
@@ -32,6 +32,7 @@ use bollard::query_parameters::{
CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions, CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions,
}; };
use bollard::Docker; use bollard::Docker;
use base64::Engine;
use futures::StreamExt; use futures::StreamExt;
use std::collections::HashMap; use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
@@ -343,6 +344,131 @@ impl MissionRuntimeProvisioner {
} }
extract_pairing_code_from_json(&buf) 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 /// Parse the JSON `{ "pairing_code": "NNNNNN", ... }` body from the
@@ -441,6 +567,76 @@ async fn sweep_once(pool: &sqlx::PgPool, grace: std::time::Duration) -> Result<(
mod tests { mod tests {
use super::*; 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] #[test]
fn container_name_is_stable_and_prefixed() { fn container_name_is_stable_and_prefixed() {
let id = Uuid::parse_str("019f84a0-f2a2-7bd0-be9b-86713ec73693").unwrap(); let id = Uuid::parse_str("019f84a0-f2a2-7bd0-be9b-86713ec73693").unwrap();
+3 -4
View File
@@ -130,11 +130,10 @@ pub(crate) async fn build_team_with_lifecycle(
.await?; .await?;
let claw_id = agent.id.as_uuid(); let claw_id = agent.id.as_uuid();
let risk = RuntimeProvisioner::default_risk_profile_for_role(&m.role); let risk = RuntimeProvisioner::default_risk_profile_for_role(&m.role);
// No workspace override on the ad-hoc team-wizard path — those // Ad-hoc team-wizard teams aren't mission-bound, so they use the
// teams aren't mission-bound so they use the default per-agent // default per-agent workspace under <install>/agents/<alias>/workspace/.
// workspace under <install>/agents/<alias>/workspace/.
provisioner provisioner
.provision_claw(claw_id, &m.model, risk, None) .provision_claw(claw_id, &m.model, risk)
.await .await
.map_err(|e| { .map_err(|e| {
eprintln!("teams: provision claw {claw_id} failed: {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`, /// Create `claw_<id>` as a live runtime agent bound to `model_alias`,
/// `risk_profile` (from the team template — controls which tools this /// `risk_profile` (from the team template — controls which tools this
/// agent gets: `toolfree` = nothing, `research_readonly` = file_read /// agent gets: `toolfree` = nothing, `research_readonly` = file_read +
/// only, `coding_readwrite` = file_read + file_write + shell, etc.), /// content_search + glob_search, `coding_readwrite` = adds file_edit +
/// and the `clawmates_door` MCP bundle. /// 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 /// NOTE ON WORKSPACE PINNING: `[agents.<alias>.workspace.path]` is an
/// dir via `[agents.<alias>.workspace.path]`. In per-mission runtime /// `Option<PathBuf>` field that the ZeroClaw config prop-schema does NOT
/// containers this is `/mission/repo` so `file_edit` / `content_search` /// expose as settable (the `Configurable` macro skips `PathBuf` from
/// / `glob_search` operate on the mission's checked-out repo instead /// property enumeration — `zeroclaw-macros/src/lib.rs`), so a
/// of the default `<install>/agents/<alias>/workspace/` sandbox /// `set_prop("agents.<alias>.workspace.path", …)` here would always 404
/// (which the agent can't populate with the mission's source files). /// 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. /// Idempotent on the create step.
pub async fn provision_claw( pub async fn provision_claw(
@@ -151,7 +156,6 @@ impl RuntimeProvisioner {
claw_id: Uuid, claw_id: Uuid,
model: &str, model: &str,
risk_profile: &str, risk_profile: &str,
workspace_path: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
let alias = claw_alias(claw_id); let alias = claw_alias(claw_id);
let model_alias = provider_alias_for(model); let model_alias = provider_alias_for(model);
@@ -189,13 +193,6 @@ impl RuntimeProvisioner {
serde_json::json!(["clawmates_door"]), serde_json::json!(["clawmates_door"]),
) )
.await?; .await?;
if let Some(path) = workspace_path {
self.set_prop(
&format!("agents.{alias}.workspace.path"),
serde_json::json!(path),
)
.await?;
}
Ok(alias) Ok(alias)
} }
@@ -125,6 +125,34 @@ level = "full"
allowed_tools = [] allowed_tools = []
excluded_tools = ["shell", "file_read", "file_write", "http_request", "browser", "composio"] 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 # The role-cast. node.role → agent alias is configured Clawmates-side via
# ZEROCLAW_AGENT_MAP (e.g. "analyst=researcher"); `scout` is the default # ZEROCLAW_AGENT_MAP (e.g. "analyst=researcher"); `scout` is the default
# fallback (ZEROCLAW_DEFAULT_AGENT) for any unmapped role. # fallback (ZEROCLAW_DEFAULT_AGENT) for any unmapped role.