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
@@ -89,6 +89,92 @@ fn build_install_script() -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The MCP configuration `claude -p --mcp-config` is pointed at.
|
||||||
|
///
|
||||||
|
/// Under `/root` with the hooks, never under `/mission/repo`: it carries a
|
||||||
|
/// bearer token, and anything written into the checkout arrives in the diff the
|
||||||
|
/// mission delivers.
|
||||||
|
pub const MCP_CONFIG_PATH: &str = "/root/toolhooks/clawmates-mcp.json";
|
||||||
|
|
||||||
|
/// Where the mission container reaches this server.
|
||||||
|
///
|
||||||
|
/// Mission containers join `clawmates_core`, the same network the API is on, so
|
||||||
|
/// the API is reachable by container name. The name differs between
|
||||||
|
/// deployments (`clawmates-server-1` locally, `clawmates_server_1` on gw-04),
|
||||||
|
/// so the default is derived from **our own** hostname — docker's embedded DNS
|
||||||
|
/// resolves a container id on a user-defined network, which makes this
|
||||||
|
/// self-configuring rather than a constant that is right in one place.
|
||||||
|
/// Measured from a sibling container: both the id and the name return 200.
|
||||||
|
pub fn api_origin() -> Option<String> {
|
||||||
|
if let Ok(v) = std::env::var("CLAWMATES_API_ORIGIN") {
|
||||||
|
if !v.trim().is_empty() {
|
||||||
|
return Some(v.trim().trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let host = std::env::var("HOSTNAME").ok()?;
|
||||||
|
let host = host.trim();
|
||||||
|
if host.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(format!("http://{host}:8080"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `--mcp-config` document: one HTTP server, carrying its own credential.
|
||||||
|
///
|
||||||
|
/// The token is a `skills:read` session and nothing else. It is written into a
|
||||||
|
/// file the agent can read — it runs `Bash` — so the only thing keeping this
|
||||||
|
/// safe is that the credential authenticates to exactly one route. See
|
||||||
|
/// `cm_auth::authenticate_scoped`.
|
||||||
|
pub fn mcp_document(origin: &str, token: &str) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"mcpServers": {
|
||||||
|
"clawmates_skills": {
|
||||||
|
"type": "http",
|
||||||
|
"url": format!("{origin}/mcp/skills"),
|
||||||
|
"headers": { "Authorization": format!("Bearer {token}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE on `--allowedTools`. The provider passes it only when the config sets
|
||||||
|
// `tools`, and the seed already does — without it `claude -p` stops mid-turn to
|
||||||
|
// ask for write permission. Whether the MCP tools ALSO need naming there is not
|
||||||
|
// documented anywhere we control, and the daemon exposes no config read to
|
||||||
|
// merge into that list safely: overwriting it would take `Write` and `Bash`
|
||||||
|
// away from every mission agent, and that failure would look like agents that
|
||||||
|
// stopped working rather than a config that was replaced.
|
||||||
|
//
|
||||||
|
// So it is left alone and the question is answered by running a mission with
|
||||||
|
// the door installed. Guessing here is how the last three defects in this file
|
||||||
|
// were introduced.
|
||||||
|
|
||||||
|
/// Write the MCP configuration into a mission container.
|
||||||
|
///
|
||||||
|
/// Returns the path on success. `None` means the mission runs without a door —
|
||||||
|
/// logged, never fatal, exactly like the hooks above. A phase that cannot
|
||||||
|
/// retrieve a skill still delivers; a phase that fails to start because a
|
||||||
|
/// config write failed delivers nothing.
|
||||||
|
pub async fn install_door(docker: &Docker, container: &str, doc: &serde_json::Value) -> Option<String> {
|
||||||
|
// `printf %s` with the JSON single-quoted, not a heredoc: the document is
|
||||||
|
// one line and contains no newline to terminate on.
|
||||||
|
let script = format!(
|
||||||
|
"mkdir -p {HOOK_DIR} && printf '%s' {} > {MCP_CONFIG_PATH} && chmod 600 {MCP_CONFIG_PATH}",
|
||||||
|
crate::vm_tool_tap::shell_quote(&doc.to_string()),
|
||||||
|
);
|
||||||
|
let argv = vec!["sh".to_string(), "-lc".to_string(), script];
|
||||||
|
match crate::container_exec::exec_as_root(docker, container, None, &argv, INSTALL_TIMEOUT).await
|
||||||
|
{
|
||||||
|
Ok(out) if out.exit_code == Some(0) => Some(MCP_CONFIG_PATH.to_string()),
|
||||||
|
other => {
|
||||||
|
eprintln!(
|
||||||
|
"container_tool_hooks: could not write the MCP config in {container} ({other:?}) — this mission runs without the skills door"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The tap file inside the mission container.
|
/// The tap file inside the mission container.
|
||||||
pub fn tap_file() -> String {
|
pub fn tap_file() -> String {
|
||||||
format!("{TAP_DIR}/tools.jsonl")
|
format!("{TAP_DIR}/tools.jsonl")
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ pub async fn on_launch(
|
|||||||
settings ({e}) — this mission's tool calls run unchecked"
|
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 first_team_id: Option<Uuid> = None;
|
||||||
let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new();
|
let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new();
|
||||||
@@ -883,3 +884,81 @@ fn default_accent_for(slot: &str) -> &'static str {
|
|||||||
_ => "#8a8a92",
|
_ => "#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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -201,6 +201,21 @@ impl RuntimeProvisioner {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Point `claude -p` at an MCP configuration.
|
||||||
|
///
|
||||||
|
/// The counterpart to [`set_claude_cli_settings`](Self::set_claude_cli_settings):
|
||||||
|
/// writing the document into the container and telling the daemon about it
|
||||||
|
/// are two halves of one thing, and doing one without the other leaves a
|
||||||
|
/// door that is installed and unreachable — which looks exactly like a door
|
||||||
|
/// nobody walked through.
|
||||||
|
pub async fn set_claude_cli_mcp_config(&self, path: &str) -> Result<(), String> {
|
||||||
|
self.set_prop(
|
||||||
|
"providers.models.claude_cli.default.mcp_config",
|
||||||
|
serde_json::json!(path),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Rebind an existing claw's model without touching its risk_profile
|
/// Rebind an existing claw's model without touching its risk_profile
|
||||||
/// or mcp_bundles. Used by the "change model" UI on the Agents page
|
/// or mcp_bundles. Used by the "change model" UI on the Agents page
|
||||||
/// so we don't accidentally demote a coding_readwrite claw back to
|
/// so we don't accidentally demote a coding_readwrite claw back to
|
||||||
|
|||||||
@@ -212,13 +212,13 @@ pub fn install_command(dir: &str) -> String {
|
|||||||
"mkdir -p {dir} && rm -f {dir}/tools.jsonl \
|
"mkdir -p {dir} && rm -f {dir}/tools.jsonl \
|
||||||
&& printf '%s' {script} > {dir}/tap.sh && chmod +x {dir}/tap.sh",
|
&& printf '%s' {script} > {dir}/tap.sh && chmod +x {dir}/tap.sh",
|
||||||
dir = dir,
|
dir = dir,
|
||||||
script = q(&hook_script(dir)),
|
script = shell_quote(&hook_script(dir)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the composed settings document.
|
/// Write the composed settings document.
|
||||||
pub fn settings_command(path: &str, settings: &Value) -> String {
|
pub fn settings_command(path: &str, settings: &Value) -> String {
|
||||||
format!("printf '%s' {} > {path}", q(&settings.to_string()))
|
format!("printf '%s' {} > {path}", shell_quote(&settings.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a drained tap.
|
/// Parse a drained tap.
|
||||||
@@ -266,7 +266,7 @@ pub fn parse(raw: &str) -> Vec<Observed> {
|
|||||||
|
|
||||||
/// Single-quote for `sh`. Local copy, same rule as the stop gate's — these two
|
/// Single-quote for `sh`. Local copy, same rule as the stop gate's — these two
|
||||||
/// modules deliberately share no code, so neither can break the other.
|
/// modules deliberately share no code, so neither can break the other.
|
||||||
fn q(s: &str) -> String {
|
pub fn shell_quote(s: &str) -> String {
|
||||||
format!("'{}'", s.replace('\'', r"'\''"))
|
format!("'{}'", s.replace('\'', r"'\''"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user