feat(missions): install the skills door, with a credential it is safe to leave
The capability has been built and undeployed since `88eef99d4`: `claude_cli` accepts `mcp_config` and passes `--mcp-config --strict-mcp-config`, so Claude Code's own MCP client can reach our skills server. What was missing was the config document and, underneath it, a credential that could be left in a container an untrusted agent reads. Now both halves happen together — the document goes in, and the daemon is told to pass it — because doing one without the other leaves a door installed and unreachable, which looks exactly like a door nobody walked through. That is the same shape as the hooks that shipped installed and inert three bugs running. The API origin defaults to our own `HOSTNAME` rather than a container name. Mission containers share `clawmates_core` with the server, and the server's name differs between deployments (`clawmates-server-1` locally, `clawmates_server_1` on gw-04); docker's embedded DNS resolves a container id on a user-defined network, so this is self-configuring. Measured from a sibling container: both the id and the name return 200. `--allowedTools` is deliberately NOT touched. The provider passes it only when `tools` is set and the seed already sets it — without it `claude -p` stops mid-turn asking for write permission. Whether MCP tools also need naming there is undocumented in anything we control, and the daemon exposes no config read to merge into the list safely; overwriting it would take `Write` and `Bash` from every mission agent, and that failure would look like agents that stopped working rather than a config that was replaced. So the question gets answered by running a mission with the door installed. Guessing is how the last three defects in this file got in. Every failure degrades to "no door", never to a failed launch. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
This commit is contained in:
co-authored by
Claude Opus 5
parent
2668191e30
commit
73f5d71c55
@@ -346,6 +346,7 @@ pub async fn on_launch(
|
||||
settings ({e}) — this mission's tool calls run unchecked"
|
||||
);
|
||||
}
|
||||
install_skills_door(pool, user_id, &mission, p).await;
|
||||
}
|
||||
let mut first_team_id: Option<Uuid> = None;
|
||||
let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new();
|
||||
@@ -883,3 +884,81 @@ fn default_accent_for(slot: &str) -> &'static str {
|
||||
_ => "#8a8a92",
|
||||
}
|
||||
}
|
||||
|
||||
/// Give this mission's agents a reachable, narrow door to the skills catalogue.
|
||||
///
|
||||
/// Two halves that must both happen: the document goes into the container, and
|
||||
/// the daemon is told to pass it to `claude -p --mcp-config`. Doing one without
|
||||
/// the other leaves a door that is installed and unreachable, which looks
|
||||
/// exactly like a door nobody walked through — the same shape as the hooks that
|
||||
/// were installed and inert.
|
||||
///
|
||||
/// # The credential
|
||||
///
|
||||
/// A `skills:read` session, not a user's. It is written into a file the agent
|
||||
/// can `cat` — it runs `Bash` with egress — so the only thing keeping this safe
|
||||
/// is that the token authenticates to exactly one route and nowhere else. See
|
||||
/// `cm_auth::AuthService::authenticate_scoped`. A full session here would be an
|
||||
/// owner-privileged API key handed to something explicitly untrusted, which is
|
||||
/// why the door went undeployed rather than being deployed the easy way.
|
||||
///
|
||||
/// Every failure degrades to "no door", never to a failed launch. A mission
|
||||
/// that cannot retrieve a skill still delivers.
|
||||
async fn install_skills_door(
|
||||
pool: &PgPool,
|
||||
user_id: cm_domain::UserId,
|
||||
mission: &cm_db::repo::missions::Mission,
|
||||
prov: &RuntimeProvisioner,
|
||||
) {
|
||||
let Some(container) = mission.runtime_container_name.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let Some(origin) = crate::container_tool_hooks::api_origin() else {
|
||||
eprintln!(
|
||||
"mission_orchestrator: no API origin for the skills door (set \
|
||||
CLAWMATES_API_ORIGIN) — mission {} runs without it",
|
||||
mission.id
|
||||
);
|
||||
return;
|
||||
};
|
||||
// Outlives the longest mission we have seen, and expires on its own so a
|
||||
// leaked container does not leave a live credential behind indefinitely.
|
||||
let auth = cm_auth::AuthService::new(pool.clone());
|
||||
let token = match auth
|
||||
.mint_scoped(user_id, cm_auth::SCOPE_SKILLS_READ, time::Duration::hours(24))
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"mission_orchestrator: could not mint a skills token ({e}) — \
|
||||
mission {} runs without the door",
|
||||
mission.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let docker = match crate::container_exec::connect() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("mission_orchestrator: cannot reach docker for the skills door: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let doc = crate::container_tool_hooks::mcp_document(&origin, &token);
|
||||
let Some(path) = crate::container_tool_hooks::install_door(&docker, container, &doc).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = prov.set_claude_cli_mcp_config(&path).await {
|
||||
eprintln!(
|
||||
"mission_orchestrator: wrote the MCP config but could not point \
|
||||
claude_cli at it ({e}) — the door is installed and unreachable"
|
||||
);
|
||||
return;
|
||||
}
|
||||
eprintln!(
|
||||
"mission_orchestrator: skills door installed for mission {} ({origin}/mcp/skills)",
|
||||
mission.id
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user