mcp door: mint workspace-owner service session for team runtime bearer
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:
co-authored by
Claude Opus 4.7
parent
8cd0c7bd01
commit
49f94a5360
@@ -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 {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user