mcp door: mint workspace-owner service session for team runtime bearer
ci / gates (push) Successful in 5s
ci / rust (push) Failing after 10s
ci / frontend (push) Successful in 25s
ci / e2e (push) Skipped
ci / publish (push) Skipped

The runtime template's static clawmates_door bearer is rejected by
cm_auth::authenticate() (needs an auth_sessions row). Every per-team
agent was getting `unauthorized: missing or invalid bearer token` and
`0 tool(s) registered from 0 server(s)`.

Add AuthService::mint_service_session + users::owner_of_workspace and
mint a 30d service session in try_team_gateway_url; inject it into the
freshly-spawned team container's config.toml [[mcp.servers]] clawmates
Authorization header via prewrite_daemon_config_with_risk (bearer arg).

Follow-up: apply the same pattern to research::spawn (per-topic) and
per-loop spawn paths.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-18 13:37:27 -07:00
co-authored by Claude Opus 4.7
parent 8cd0c7bd01
commit 49f94a5360
4 changed files with 124 additions and 9 deletions
+50 -9
View File
@@ -598,33 +598,73 @@ pub fn team_state_root(team_id: Uuid) -> std::path::PathBuf {
fn prewrite_daemon_config_with_risk( fn prewrite_daemon_config_with_risk(
state_host_path: &Path, state_host_path: &Path,
risk_profile_override: Option<&str>, risk_profile_override: Option<&str>,
mcp_bearer_override: Option<&str>,
) -> Result<(), String> { ) -> Result<(), String> {
prewrite_daemon_config(state_host_path)?; prewrite_daemon_config(state_host_path)?;
let Some(override_name) = risk_profile_override else { if risk_profile_override.is_none() && mcp_bearer_override.is_none() {
return Ok(()); return Ok(());
}; }
let cfg_path = state_host_path.join(".zeroclaw/config.toml"); let cfg_path = state_host_path.join(".zeroclaw/config.toml");
let src = std::fs::read_to_string(&cfg_path) let src = std::fs::read_to_string(&cfg_path)
.map_err(|e| format!("read {}: {e}", cfg_path.display()))?; .map_err(|e| format!("read {}: {e}", cfg_path.display()))?;
let mut out = String::with_capacity(src.len()); let mut out = String::with_capacity(src.len());
let mut in_agent_block = false; let mut in_agent_block = false;
let replacement = format!("risk_profile = \"{override_name}\"\n"); let mut in_mcp_clawmates = false;
let mut current_mcp_name: Option<String> = None;
let risk_replacement = risk_profile_override.map(|n| format!("risk_profile = \"{n}\"\n"));
for line in src.lines() { for line in src.lines() {
let trimmed = line.trim_start();
if line.starts_with("[agents.") { if line.starts_with("[agents.") {
in_agent_block = true; in_agent_block = true;
in_mcp_clawmates = false;
current_mcp_name = None;
out.push_str(line); out.push_str(line);
out.push('\n'); out.push('\n');
continue; continue;
} }
if in_agent_block && line.starts_with('[') { if line.starts_with("[[mcp.servers]]") {
in_agent_block = false; in_agent_block = false;
} in_mcp_clawmates = false;
if in_agent_block && line.starts_with("risk_profile = \"") { current_mcp_name = Some(String::new());
out.push_str(&replacement);
} else {
out.push_str(line); out.push_str(line);
out.push('\n'); out.push('\n');
continue;
} }
if line.starts_with('[') {
in_agent_block = line.starts_with("[agents.");
in_mcp_clawmates = false;
current_mcp_name = None;
}
// Track name within an [[mcp.servers]] block so we only rewrite
// the `clawmates` server's Authorization header, not others.
if current_mcp_name.is_some() && trimmed.starts_with("name") {
if let Some(v) = trimmed.split('=').nth(1) {
let v = v.trim().trim_matches('"');
if v == "clawmates" {
in_mcp_clawmates = true;
}
}
}
if in_mcp_clawmates
&& mcp_bearer_override.is_some()
&& trimmed.starts_with("headers")
&& trimmed.contains("Authorization")
{
let bearer = mcp_bearer_override.unwrap();
out.push_str(&format!(
"headers = {{ Authorization = \"Bearer {bearer}\" }}\n"
));
continue;
}
if in_agent_block
&& trimmed.starts_with("risk_profile = \"")
&& let Some(r) = &risk_replacement
{
out.push_str(r);
continue;
}
out.push_str(line);
out.push('\n');
} }
std::fs::write(&cfg_path, out).map_err(|e| format!("write {}: {e}", cfg_path.display()))?; std::fs::write(&cfg_path, out).map_err(|e| format!("write {}: {e}", cfg_path.display()))?;
Ok(()) Ok(())
@@ -645,6 +685,7 @@ pub async fn spawn_team(
repo_host_path: &Path, repo_host_path: &Path,
state_host_path: &Path, state_host_path: &Path,
risk_profile: Option<&str>, risk_profile: Option<&str>,
mcp_bearer: Option<&str>,
) -> Result<SpawnedContainer, String> { ) -> Result<SpawnedContainer, String> {
let name = team_container_name_for(team_id); let name = team_container_name_for(team_id);
let gateway_url = format!("http://{name}:42617"); let gateway_url = format!("http://{name}:42617");
@@ -672,7 +713,7 @@ pub async fn spawn_team(
std::fs::create_dir_all(state_host_path) std::fs::create_dir_all(state_host_path)
.map_err(|e| format!("mkdir {}: {e}", state_host_path.display()))?; .map_err(|e| format!("mkdir {}: {e}", state_host_path.display()))?;
prewrite_daemon_config_with_risk(state_host_path, risk_profile)?; prewrite_daemon_config_with_risk(state_host_path, risk_profile, mcp_bearer)?;
let mut mounts = vec![ let mut mounts = vec![
Mount { Mount {
+33
View File
@@ -676,12 +676,26 @@ async fn try_team_gateway_url(
}) })
.ok()?; .ok()?;
let state_root = crate::research_container::team_state_root(team_id); let state_root = crate::research_container::team_state_root(team_id);
// Mint a workspace-owner service session so the team runtime's
// clawmates_door MCP calls pass cm-auth (the static bearer baked
// into the template config isn't a valid auth_sessions row and
// gets 401'd, leaving every agent with 0 tools).
let mcp_bearer = mint_workspace_service_token(pool, workspace_id)
.await
.map_err(|e| {
eprintln!("try_team_gateway_url: mint MCP bearer failed for team {team_id}: {e}");
e
})
.ok();
let spawned = crate::research_container::spawn_team( let spawned = crate::research_container::spawn_team(
&docker, &docker,
team_id, team_id,
std::path::Path::new(&repo_path), std::path::Path::new(&repo_path),
&state_root, &state_root,
risk_profile.as_deref(), risk_profile.as_deref(),
mcp_bearer.as_deref(),
) )
.await .await
.map_err(|e| { .map_err(|e| {
@@ -705,3 +719,22 @@ async fn try_team_gateway_url(
Some(spawned.gateway_url) Some(spawned.gateway_url)
} }
/// Mint a long-lived session token for the workspace owner. Used to
/// authorize internal service callers (e.g. per-team ZeroClaw runtimes
/// hitting our clawmates_door MCP endpoint) without threading a real
/// user session through the runtime template.
async fn mint_workspace_service_token(
pool: &PgPool,
workspace_id: WorkspaceId,
) -> Result<String, String> {
let owner = cm_db::repo::users::owner_of_workspace(pool, workspace_id)
.await
.map_err(|e| format!("owner_of_workspace: {e}"))?;
let auth = cm_auth::AuthService::new(pool.clone());
let token = auth
.mint_service_session(owner, time::Duration::days(30))
.await
.map_err(|e| format!("mint_service_session: {e}"))?;
Ok(token.secret().to_string())
}
+22
View File
@@ -289,6 +289,28 @@ impl AuthService {
}) })
} }
/// Mint a long-lived opaque session for an internal service caller
/// (e.g. the per-team ZeroClaw runtime calling back into the MCP door).
/// Returns the plaintext token — the caller is responsible for handing
/// it to the exact process that needs it and not persisting it broadly.
pub async fn mint_service_session(
&self,
user_id: UserId,
ttl: Duration,
) -> Result<SessionToken, AuthError> {
let token = SessionToken::generate();
sqlx::query!(
"INSERT INTO auth_sessions (token_hash, user_id, expires_at)
VALUES ($1, $2, $3)",
hash_token(token.secret()),
user_id.as_uuid(),
OffsetDateTime::now_utc() + ttl,
)
.execute(&self.pool)
.await?;
Ok(token)
}
/// Ends the session for this token. /// Ends the session for this token.
pub async fn logout(&self, token_secret: &str) -> Result<(), AuthError> { pub async fn logout(&self, token_secret: &str) -> Result<(), AuthError> {
sqlx::query!( sqlx::query!(
+19
View File
@@ -72,6 +72,25 @@ pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<User, DbError>
}) })
} }
/// The workspace's owner (earliest-joined user with role=owner). Used by
/// internal service paths (e.g. per-team runtime MCP auth) that need to
/// mint a bearer scoped to the workspace but don't have a caller in hand.
pub async fn owner_of_workspace(
pool: &PgPool,
workspace_id: WorkspaceId,
) -> Result<UserId, DbError> {
let row = sqlx::query!(
"SELECT id FROM users
WHERE workspace_id = $1 AND role = 'owner'
ORDER BY created_at, id
LIMIT 1",
workspace_id.as_uuid(),
)
.fetch_one(pool)
.await?;
Ok(UserId::from(row.id))
}
/// Members table for the Team page (§8.3), in join order. /// Members table for the Team page (§8.3), in join order.
pub async fn list_by_workspace( pub async fn list_by_workspace(
pool: &PgPool, pool: &PgPool,