mcp door: extend service-session bearer to per-topic + per-loop spawns
ci / gates (push) Successful in 5s
ci / rust (push) Failing after 9s
ci / frontend (push) Successful in 27s
ci / e2e (push) Skipped
ci / publish (push) Skipped

Per-team runtimes had their MCP bearer swap wired in the prior slice,
but per-topic (research_container::spawn) and per-loop (spawn_loop)
containers still baked the stale template bearer and 401'd every
tools/list. Same fix, extended: mint a workspace-owner service session
via runtime_provision::mint_workspace_service_token and inject via
prewrite_daemon_config_with_risk.

Move mint_workspace_service_token from topology_worker into
runtime_provision so all three spawn call sites share the helper.

Callers updated: routes/research.rs (start_topic), routes/research_setup.rs
(prepare_topic_runtime), routes/loops.rs (ensure_loop_container).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-18 16:14:38 -07:00
co-authored by Claude Opus 4.7
parent 6504ed6062
commit 657b666219
6 changed files with 82 additions and 24 deletions
+4 -2
View File
@@ -187,6 +187,7 @@ pub async fn spawn(
topic_id: Uuid, topic_id: Uuid,
repo_host_path: &Path, repo_host_path: &Path,
state_host_path: &Path, state_host_path: &Path,
mcp_bearer: Option<&str>,
) -> Result<SpawnedContainer, String> { ) -> Result<SpawnedContainer, String> {
let name = container_name_for(topic_id); let name = container_name_for(topic_id);
let gateway_url = format!("http://{name}:42617"); let gateway_url = format!("http://{name}:42617");
@@ -225,7 +226,7 @@ pub async fn spawn(
// connect. These containers live on the private clawmates_core // connect. These containers live on the private clawmates_core
// docker network and only accept traffic from the API server, so // docker network and only accept traffic from the API server, so
// disabling pairing here is safe. // disabling pairing here is safe.
prewrite_daemon_config(state_host_path)?; prewrite_daemon_config_with_risk(state_host_path, None, mcp_bearer)?;
let mut mounts = vec![ let mut mounts = vec![
Mount { Mount {
@@ -447,6 +448,7 @@ pub async fn spawn_loop(
docker: &Docker, docker: &Docker,
loop_id: Uuid, loop_id: Uuid,
state_host_path: &Path, state_host_path: &Path,
mcp_bearer: Option<&str>,
) -> Result<SpawnedContainer, String> { ) -> Result<SpawnedContainer, String> {
let name = loop_container_name_for(loop_id); let name = loop_container_name_for(loop_id);
let gateway_url = format!("http://{name}:42617"); let gateway_url = format!("http://{name}:42617");
@@ -475,7 +477,7 @@ pub async fn spawn_loop(
.map_err(|e| format!("mkdir {}: {e}", state_host_path.display()))?; .map_err(|e| format!("mkdir {}: {e}", state_host_path.display()))?;
// Same pairing bypass as `spawn` — per-loop containers are // Same pairing bypass as `spawn` — per-loop containers are
// ephemeral, on a private docker network, and freshly created. // ephemeral, on a private docker network, and freshly created.
prewrite_daemon_config(state_host_path)?; prewrite_daemon_config_with_risk(state_host_path, None, mcp_bearer)?;
let mounts = vec![Mount { let mounts = vec![Mount {
target: Some("/zeroclaw-data".into()), target: Some("/zeroclaw-data".into()),
+18 -1
View File
@@ -245,7 +245,24 @@ async fn ensure_loop_container(pool: &PgPool, workspace_id: Uuid, loop_id: Uuid)
} }
}; };
let state_root = loop_state_root().join(loop_id.to_string()).join("state"); let state_root = loop_state_root().join(loop_id.to_string()).join("state");
let spawned = match crate::research_container::spawn_loop(&docker, loop_id, &state_root).await { let mcp_bearer = crate::runtime_provision::mint_workspace_service_token(
pool,
cm_domain::WorkspaceId::from(workspace_id),
)
.await
.map_err(|e| {
eprintln!("loops::ensure_loop_container({loop_id}): mint MCP bearer failed: {e}");
e
})
.ok();
let spawned = match crate::research_container::spawn_loop(
&docker,
loop_id,
&state_root,
mcp_bearer.as_deref(),
)
.await
{
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
eprintln!("loops::ensure_loop_container({loop_id}): spawn failed: {e}"); eprintln!("loops::ensure_loop_container({loop_id}): spawn failed: {e}");
+19 -1
View File
@@ -772,7 +772,25 @@ pub async fn start_topic(
if let Some(repo_path) = repo_path_for_container { if let Some(repo_path) = repo_path_for_container {
match crate::research_container::connect() { match crate::research_container::connect() {
Ok(docker) => { Ok(docker) => {
match crate::research_container::spawn(&docker, id, &repo_path, &state_root).await { let mcp_bearer = crate::runtime_provision::mint_workspace_service_token(
&state.pool,
user.workspace_id,
)
.await
.map_err(|e| {
eprintln!("start_topic: mint MCP bearer failed: {e}");
e
})
.ok();
match crate::research_container::spawn(
&docker,
id,
&repo_path,
&state_root,
mcp_bearer.as_deref(),
)
.await
{
Ok(spawned) => { Ok(spawned) => {
if let Err(e) = cm_db::repo::research_topics::set_zeroclaw_container( if let Err(e) = cm_db::repo::research_topics::set_zeroclaw_container(
&state.pool, &state.pool,
+19 -1
View File
@@ -93,7 +93,25 @@ pub async fn prepare_topic_runtime(pool: &PgPool, workspace_id: Uuid, topic_id:
return; return;
} }
}; };
match crate::research_container::spawn(&docker, topic_id, &repo_path, &state_root).await { let mcp_bearer = crate::runtime_provision::mint_workspace_service_token(
pool,
cm_domain::WorkspaceId::from(workspace_id),
)
.await
.map_err(|e| {
eprintln!("prepare_topic_runtime({topic_id}): mint MCP bearer failed: {e}");
e
})
.ok();
match crate::research_container::spawn(
&docker,
topic_id,
&repo_path,
&state_root,
mcp_bearer.as_deref(),
)
.await
{
Ok(spawned) => { Ok(spawned) => {
if let Err(e) = cm_db::repo::research_topics::set_zeroclaw_container( if let Err(e) = cm_db::repo::research_topics::set_zeroclaw_container(
pool, pool,
+21
View File
@@ -11,8 +11,29 @@
//! prompt; the claw's rich `system_prompt` remains its chat-path identity. //! prompt; the claw's rich `system_prompt` remains its chat-path identity.
//! Injecting per-claw persona into runtime turns is a fast-follow. //! Injecting per-claw persona into runtime turns is a fast-follow.
use cm_domain::WorkspaceId;
use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
/// Mint a long-lived session token for the workspace owner. Used by
/// internal service callers (per-team + per-topic + per-loop ZeroClaw
/// runtimes hitting our clawmates_door MCP endpoint) without threading
/// a real user session through the runtime template.
pub 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())
}
/// The runtime agent alias for a claw id. /// The runtime agent alias for a claw id.
pub fn claw_alias(claw_id: Uuid) -> String { pub fn claw_alias(claw_id: Uuid) -> String {
format!("claw_{}", claw_id.simple()) format!("claw_{}", claw_id.simple())
+1 -19
View File
@@ -681,7 +681,7 @@ async fn try_team_gateway_url(
// clawmates_door MCP calls pass cm-auth (the static bearer baked // clawmates_door MCP calls pass cm-auth (the static bearer baked
// into the template config isn't a valid auth_sessions row and // into the template config isn't a valid auth_sessions row and
// gets 401'd, leaving every agent with 0 tools). // gets 401'd, leaving every agent with 0 tools).
let mcp_bearer = mint_workspace_service_token(pool, workspace_id) let mcp_bearer = crate::runtime_provision::mint_workspace_service_token(pool, workspace_id)
.await .await
.map_err(|e| { .map_err(|e| {
eprintln!("try_team_gateway_url: mint MCP bearer failed for team {team_id}: {e}"); eprintln!("try_team_gateway_url: mint MCP bearer failed for team {team_id}: {e}");
@@ -720,21 +720,3 @@ 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())
}